Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .changeset/legal-release-image-tags.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
"nostream": patch
"nostream-website": patch
---

fix(ci): publish legal image tags and keep `latest` off prereleases

A changesets relay release is tagged `nostream@3.7.0`, and `@` is not legal in a
Docker tag, so the `type=ref,event=tag` rule handed buildx a tag it refused: the
first live run of the Release workflow failed with `invalid tag ...: invalid
reference format` and published no image for the release (#964).

The tags are now computed by a shell step, so every tag is a value this
repository chose and can check: the bare version, `vX.Y.Z` as well beside it for
a legacy tag, and `latest` only when the release is not a prerelease. Each tag is
validated against Docker's grammar before the build, so an illegal one fails by
name at that step instead of at buildx.

`docs/CI-MIGRATION.md`, `docs/DEPLOYMENT.md` and `docs/DEPLOY-RUNBOOK.md` no
longer promise `nostream@X.Y.Z` as a pinnable image tag, and the runbook now
names the receiver change that subscribing to `release` events depends on.
5 changes: 4 additions & 1 deletion .github/workflows/checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,10 @@ jobs:
- 'Dockerfile*'
- 'docker-compose*.yml'
- '.nvmrc'
- '.github/workflows/checks.yml'
# A unit test reads these workflows, so a change to any of them
# must run the suite that asserts on it. #965 changed release.yml
# and skipped the very tests that change broke.
- '.github/workflows/**'

commit-lint:
name: Lint commits
Expand Down
70 changes: 42 additions & 28 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,37 +33,51 @@ jobs:
uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
# Stated rather than inherited from the action's defaults: the deploy
# path pulls `:latest`, so a released image must carry it. Naming the
# tags here is what makes `latest` something this repository promises
# rather than a behaviour of a third-party action.
#
# Changesets tags a relay release `nostream@3.6.2`, which is not a
# semver. A semver rule derives its input from the git ref and emits
# NOTHING for it, and with no version the `latest` rule is skipped as
# well - so a relay release published no tag at all. `prefix=` cannot
# help (it is rendered after parsing), and `value=` cannot help either
# (it replaces the whole tag, leaving no room for the prefix).
#
# So the semver-shaped tag comes from a `match` rule that extracts the
# version out of the relay tag, and the release tag itself is resolved
# from the git ref, unchanged. The semver rule runs first so the bare
# version is the primary tag; the release tag is kept beside it, which
# is what a host sees on the GitHub release and pins.
#
# Verified by running this action revision's own Meta class over this
# block: a relay release yields `3.6.2`, `nostream@3.6.2` and `latest`;
# a legacy `v3.4.1` yields `3.4.1`, `v3.4.1` and `latest`; a website
# release matches neither semver nor `match`, and the job's `if:`
# never lets that job run.
tags: |
type=match,pattern=nostream@(.+),group=1
type=ref,event=tag
type=raw,value=latest
# The image tags are computed here rather than by metadata-action's rules
# because a changesets relay release is tagged `nostream@3.7.0`, and `@` is
# not legal in a Docker tag: it separates the repository path from a digest
# in an image reference. A `type=ref,event=tag` rule emitted that string
# verbatim, and buildx refused the whole build with `invalid tag ...:
# invalid reference format`, so a published release produced no image at
# all (#964). Nothing in the action's output is legal just because the
# action emitted it, so every tag is checked against Docker's grammar here
# and an illegal one fails by name instead of at buildx.
- name: Compute image tags
id: tags
Comment thread
greptile-apps[bot] marked this conversation as resolved.
shell: bash
env:
RELEASE_TAG: ${{ github.event.release.tag_name }}
PRERELEASE: ${{ github.event.release.prerelease }}
run: |
set -euo pipefail
image="$REGISTRY/$IMAGE_NAME"
# `nostream@3.7.0` -> `3.7.0`; the pre-changesets form `v3.4.1` -> `3.4.1`.
version="${RELEASE_TAG##*@}"
version="${version#v}"
tags="$image:$version"
# A legacy `vX.Y.Z` release also keeps the form hosts pinned before the
# changesets migration. A changesets tag has no legal form of its own.
case "$RELEASE_TAG" in
v*) tags="$tags,$image:$RELEASE_TAG" ;;
esac
# `latest` is the tag both deploy stacks pull, so a prerelease must not
# move it onto a test build.
if [ "$PRERELEASE" != "true" ]; then
tags="$tags,$image:latest"
fi
for tag in ${tags//,/ }; do
name="${tag##*:}"
if ! printf '%s' "$name" | grep -qE '^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$'; then
echo "::error::'$name' is not a legal Docker tag (release tag: $RELEASE_TAG)"
exit 1
fi
done
echo "tags=$tags" >> "$GITHUB_OUTPUT"
echo "publishing: $tags"
- name: Build and push Docker image
uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
tags: ${{ steps.tags.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
6 changes: 4 additions & 2 deletions docs/CI-MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ publishing any container images.
## Action required

Deployments tracking `ghcr.io/cameri/nostream:main` should be updated to track released versions,
such as `ghcr.io/cameri/nostream:latest` or pinned release tags (e.g. `ghcr.io/cameri/nostream:nostream@3.6.2`,
published as-is from the release tag, or the bare `ghcr.io/cameri/nostream:3.6.2`).
such as `ghcr.io/cameri/nostream:latest` or a pinned version tag such as
`ghcr.io/cameri/nostream:3.6.2`. The release tag itself (`nostream@3.6.2`) is not an
image tag: `@` is not legal in a Docker tag, so each release publishes its bare
version and `latest` instead.
See [`deploy/README.md`](../deploy/README.md) and [`docs/DEPLOY-RUNBOOK.md`](DEPLOY-RUNBOOK.md).
15 changes: 9 additions & 6 deletions docs/DEPLOY-RUNBOOK.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,15 @@ pulls `:main` will never see a new image. The on-host pull stack must track the
release instead:

1. `NOSTREAM_TAG` in `/opt/nostream/webhook/.env` set to `latest`.
2. The GitHub webhook subscribed to **releases** as well as **push**, because
the receiver ignores anything that is not a push to the watched branch.

Both live in `containers/nostream-dev-webhook/` (the versioned source) with its
own README. Until both are done, deployments are manual: pull the tag by hand
and continue at step 2 below.
2. The GitHub webhook subscribed to **releases** as well as **push**.
3. The receiver changed to accept a `release` event. Subscribing alone does
nothing: the receiver ignores every event that is not a push to the watched
branch, so step 2 delivers a delivery it then discards. It must treat a
`release` event whose tag is a relay release as "pull `NOSTREAM_TAG`".

The receiver is not part of this repository, so step 3 is a change to whatever
deployment owns it. Until all three are done, deployments are manual: pull the
tag by hand and continue at step 2 below.

The webhook pull stack **loads images only**. It does **not** run migrations or
recreate relay containers. This runbook closes that gap.
Expand Down
10 changes: 7 additions & 3 deletions docs/DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,12 @@

The `ghcr.io/cameri/nostream` container image is published automatically by the
Release workflow (`release.yml`) whenever an official GitHub release is published.
For release `nostream@3.6.2` the workflow publishes three tags: `latest`, the release tag itself
(`nostream@3.6.2`, which is what a host pins), and the bare version (`3.6.2`).
For release `nostream@3.6.2` the workflow publishes two tags: the bare version
(`3.6.2`, which is what a host pins) and `latest`. A prerelease publishes only its
version, so it cannot move `latest` onto a test build. The release tag itself
(`nostream@3.6.2`) is never an image tag — `@` is not legal in a Docker tag — and
the `vX.Y.Z` form used before the changesets migration also publishes `vX.Y.Z`
beside its bare version.

Publishing is triggered only on published relay releases — the changesets tag `nostream@X.Y.Z`, or the
`vX.Y.Z` form used before the changesets migration. A `nostream-website@X.Y.Z` release publishes no
Expand All @@ -12,7 +16,7 @@ image:
```text
release: published (nostream@X.Y.Z)
→ build-and-push-image
→ ghcr.io/cameri/nostream:{nostream@X.Y.Z, X.Y.Z, latest}
→ ghcr.io/cameri/nostream:{X.Y.Z, latest}
```

CI Checks (`checks.yml`) does not build or publish container images on push to
Expand Down
89 changes: 68 additions & 21 deletions test/unit/ci/publish-container-image.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import chai from 'chai'
import { existsSync, readFileSync } from 'fs'
import { execFileSync } from 'child_process'
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
import { load } from 'js-yaml'
import { tmpdir } from 'os'
import { join } from 'path'

const { expect } = chai
Expand All @@ -12,7 +14,7 @@ type Workflow = {
if?: string
needs?: string[]
permissions?: Record<string, string>
steps?: Array<{ id?: string; with?: Record<string, string> }>
steps?: Array<{ id?: string; run?: string; with?: Record<string, string> }>
}
>
}
Expand Down Expand Up @@ -45,8 +47,48 @@ const evaluateReleaseGate = (tagName: string): boolean => {
return new Function('tagName', 'startsWith', `return ${js}`)(tagName, startsWith) as boolean
}

const metaTags = (workflow: Workflow): string =>
((workflow.jobs['build-and-push-image'].steps ?? []).find((step) => step.id === 'meta')?.with?.tags ?? '')
// The tags are computed by a `run:` block, not by metadata-action's rules,
// because a changesets release tag is `nostream@X.Y.Z` and `@` is not legal in a
// Docker tag (#964). The block is executed here rather than string-matched: the
// defect it replaced was a tag nobody validated, only compared.
const tagStep = (workflow: Workflow): string => {
const step = (workflow.jobs['build-and-push-image'].steps ?? []).find((entry) => entry.id === 'tags')
expect(step, 'release.yml must compute its image tags').to.exist
const run = step?.run ?? ''
expect(run, 'the tag step must compute the tags itself, not inherit them').to.not.equal('')
return run
}

// Executes the step's own `run:` block with the two inputs it maps from the
// release event, and returns the tags it wrote to the job output.
const imageTags = (workflow: Workflow, releaseTag: string, prerelease: boolean): string[] => {
const dir = mkdtempSync(join(tmpdir(), 'release-tags-'))
try {
const out = join(dir, 'github-output')
writeFileSync(out, '')
execFileSync('bash', ['-c', tagStep(workflow)], {
stdio: 'pipe',
env: {
...process.env,
REGISTRY: 'ghcr.io',
IMAGE_NAME: 'cameri/nostream',
RELEASE_TAG: releaseTag,
PRERELEASE: String(prerelease),
GITHUB_OUTPUT: out,
},
})
const line = readFileSync(out, 'utf-8')
.split('\n')
.find((entry) => entry.startsWith('tags='))
expect(line, 'the step must write its tags to the job output').to.exist
return (line ?? '')
.slice('tags='.length)
.split(',')
.map((tag) => tag.replace('ghcr.io/cameri/nostream:', ''))
} finally {
rmSync(dir, { recursive: true, force: true })
}
}

describe('container image publishing workflow', () => {
const checksWorkflow = load(
Expand Down Expand Up @@ -86,26 +128,31 @@ describe('container image publishing workflow', () => {
})

// The deploy path pulls `:latest`, so the release must actually push it.
// Without this the metadata action's defaults are the only thing producing
// the tag, and a silent change in that action would strand every host on the
// last image it managed to pull.
it('tags the released image latest, which is what deploys pull', () => {
const tags = metaTags(releaseWorkflow)
expect(tags, 'release.yml must declare its tags, not inherit the defaults').to.include('type=raw,value=latest')
it('tags a stable release with its version and latest', () => {
expect(imageTags(releaseWorkflow, 'nostream@3.7.0', false)).to.deep.equal(['3.7.0', 'latest'])
})

// `latest` is the tag both deploy stacks pull, so a prerelease must not move
// it onto a test build.
it('does not move latest for a prerelease', () => {
expect(imageTags(releaseWorkflow, 'nostream@3.8.0-rc.1', true)).to.deep.equal(['3.8.0-rc.1'])
})

// Releases cut before the changesets migration are tagged `vX.Y.Z`, and a host
// pinned to that form must still resolve after one is republished.
it('keeps the v-prefixed tag for a pre-changesets release', () => {
expect(imageTags(releaseWorkflow, 'v3.4.1', false)).to.deep.equal(['3.4.1', 'v3.4.1', 'latest'])
})

// A semver rule's input comes from the git ref, so a `nostream@X.Y.Z` tag
// makes it emit nothing - and with no version, the `latest` rule is skipped
// too. A `match` rule is what produces the version tag for that shape.
it('derives a version tag from the changesets release tag', () => {
expect(metaTags(releaseWorkflow)).to.include('type=match,pattern=nostream@(.+),group=1')
// The defect this replaced: `nostream@3.7.0` is a release tag, not a Docker
// tag, and `@` is illegal in one. It must fail here, by name, rather than at
// buildx as `invalid reference format` after the image has been built.
it('refuses a release tag whose version is not a legal Docker tag', () => {
expect(() => imageTags(releaseWorkflow, 'nostream@3.7.0+build', false)).to.throw(/not a legal Docker tag/)
})

// The release tag is what a host sees on the GitHub release and pins, and no
// semver rule can reproduce its `nostream@` prefix (a `prefix=` is rendered
// after parsing, a `value=` replaces the whole tag). The plain ref rule
// publishes it unchanged.
it('publishes the release tag name itself, which the deploy docs pull', () => {
expect(metaTags(releaseWorkflow)).to.include('type=ref,event=tag')
it('builds with the tags the step computed', () => {
const build = (releaseWorkflow.jobs['build-and-push-image'].steps ?? []).find((step) => step.with?.tags)
expect(build?.with?.tags, 'the build must consume the computed tags').to.equal('${{ steps.tags.outputs.tags }}')
})
})
Loading