From ab5f62153d57497929e0c2961a46f78421b944f3 Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Sat, 24 Oct 2026 08:50:53 -0400 Subject: [PATCH 1/2] ci(release): 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 with "invalid tag ...: invalid reference format". The Release workflow's first live run (every earlier run was skipped by the old v* guard) therefore published no image for nostream@3.7.0. Compute the tags in a shell step instead, so each one is a value this repository chose and can check: the bare version, vX.Y.Z beside it for a legacy tag, and latest only when the release is not a prerelease. Validate each tag against Docker's grammar before the build, so an illegal one fails by name at that step rather than at buildx. The docs repeated the impossible tag as a pinnable one; they now name only what the workflow publishes, and the runbook states the receiver change that subscribing to release events depends on. Closes #964 --- .changeset/legal-release-image-tags.md | 21 ++++++++ .github/workflows/release.yml | 70 +++++++++++++++----------- docs/CI-MIGRATION.md | 6 ++- docs/DEPLOY-RUNBOOK.md | 15 +++--- docs/DEPLOYMENT.md | 10 ++-- 5 files changed, 83 insertions(+), 39 deletions(-) create mode 100644 .changeset/legal-release-image-tags.md diff --git a/.changeset/legal-release-image-tags.md b/.changeset/legal-release-image-tags.md new file mode 100644 index 000000000..ad18fa91c --- /dev/null +++ b/.changeset/legal-release-image-tags.md @@ -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. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4eb4afb9c..27e307bee 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 + 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 }} diff --git a/docs/CI-MIGRATION.md b/docs/CI-MIGRATION.md index 0ba5efce6..4b6eb8ffa 100644 --- a/docs/CI-MIGRATION.md +++ b/docs/CI-MIGRATION.md @@ -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). diff --git a/docs/DEPLOY-RUNBOOK.md b/docs/DEPLOY-RUNBOOK.md index 7f017b65a..28c5d101f 100644 --- a/docs/DEPLOY-RUNBOOK.md +++ b/docs/DEPLOY-RUNBOOK.md @@ -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. diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 4b37a701c..2e205b14a 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -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 @@ -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 From 461e0047d19c1f631e5ce7016019673314349a3c Mon Sep 17 00:00:00 2001 From: chappie-daemon Date: Sat, 24 Oct 2026 09:09:50 -0400 Subject: [PATCH 2/2] test(ci): exercise the tag step instead of matching the rules it replaced Three unit tests asserted on release.yml's metadata-action tag block, which this branch deletes, so they failed on a branch whose CI skipped them: the changed-path filter watched checks.yml and not release.yml. Replace them with a test that runs the step's own run: block for a stable release, a prerelease, a legacy v tag and an illegal version, and add .github/workflows/** to the filter so a workflow change always runs the suite that reads workflows. Mutation-checked: removing the prerelease gate fails the prerelease test, and removing the grammar check fails the illegal-tag test. --- .github/workflows/checks.yml | 5 +- test/unit/ci/publish-container-image.spec.ts | 89 +++++++++++++++----- 2 files changed, 72 insertions(+), 22 deletions(-) diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index da817fc3b..8c1d4c523 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -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 diff --git a/test/unit/ci/publish-container-image.spec.ts b/test/unit/ci/publish-container-image.spec.ts index fc6bd6901..3b1a9095a 100644 --- a/test/unit/ci/publish-container-image.spec.ts +++ b/test/unit/ci/publish-container-image.spec.ts @@ -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 @@ -12,7 +14,7 @@ type Workflow = { if?: string needs?: string[] permissions?: Record - steps?: Array<{ id?: string; with?: Record }> + steps?: Array<{ id?: string; run?: string; with?: Record }> } > } @@ -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( @@ -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 }}') }) })