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
129 changes: 103 additions & 26 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,32 @@ name: Release
#
# The catalog lists a plugin only from an immutable release of the repository
# its source lives in, with a build attestation made by that repository's own
# workflow, on a GitHub-hosted runner, for the release's tag
# (`gh attestation verify <file> --repo openbrf/example-plugin
# --source-ref refs/tags/v<version> --deny-self-hosted-runners`). So this runs
# on the tag push and nowhere else. Do not publish the release from the GitHub
# UI first: that creates the tag, the tag push then starts this workflow after
# the release is already immutable, and the upload fails.
# release workflow, at this path, on a GitHub-hosted runner, in a run on the
# release's tag:
#
# gh attestation verify <file> --repo openbrf/example-plugin
# --signer-workflow openbrf/example-plugin/.github/workflows/release.yml
# --source-ref refs/tags/v<version> --deny-self-hosted-runners
#
# So this runs on the tag push and nowhere else, and keeps its path. The
# catalog also asks GitHub whether the commit the attestation names is on
# `main`: the check below that the tag is on `main` runs as the tagged commit
# has it, so it can be edited away on a branch, and the catalog does not rely
# on it.
#
# Do not publish the release from the GitHub UI first: that creates the tag,
# the tag push then starts this workflow after the release is already
# immutable, and the upload fails.
#
# Two jobs, so that nothing installed from the lockfile runs where an
# attestation can be signed. `build` installs, tests and packs with read
# access only, and hands the tarball and its sha512 to `publish`. `publish`
# installs and runs none of the lockfile's packages: it holds the downloaded
# tarball to that sha512, attests it and creates the release. Code that runs at build time
# can still change what it builds, which the tests and the catalog's checks
# are there for, but it never holds the token that signs an attestation, so
# it cannot attest anything else as this workflow's output.
# `test/release-workflow.test.ts` holds the workflow to this split.
on:
push:
tags: ["v*"]
Expand All @@ -22,16 +42,15 @@ concurrency:
permissions: {}

jobs:
release:
name: Pack, attest and publish
build:
name: Build, test, pack and check
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
# Create the release and upload the tarball.
contents: write
# The signing certificate for the attestation, and its record.
id-token: write
attestations: write
contents: read
outputs:
tarball: ${{ steps.pack.outputs.tarball }}
sha512: ${{ steps.pack.outputs.sha512 }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
Expand All @@ -50,7 +69,6 @@ jobs:
echo "::error::v${version} points at a commit that is not on main. Release only what main has merged."
exit 1
fi
echo "VERSION=${version}" >> "${GITHUB_ENV}"

- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0

Expand All @@ -59,24 +77,80 @@ jobs:
node-version-file: .nvmrc
# No dependency cache: what goes into a release is installed from
# the lockfile alone.
package-manager-cache: false

- run: pnpm install --frozen-lockfile
- run: pnpm typecheck
# Builds `dist/`, which the pack below takes as it is.
- run: pnpm test

- name: Pack the tarball
id: pack
run: |
mkdir release
npm pack --ignore-scripts --json --pack-destination release > release/pack.json
filename="$(node -p 'require("./release/pack.json")[0].filename')"
rm release/pack.json
sha512="sha512-$(openssl dgst -sha512 -binary "release/${filename}" | base64 -w0)"
echo "TARBALL=release/${filename}" >> "${GITHUB_ENV}"
echo "tarball=${filename}" >> "${GITHUB_OUTPUT}"
echo "sha512=${sha512}" >> "${GITHUB_OUTPUT}"

# The same file is checked, attested and uploaded: what the catalog
# verifies is byte for byte what passed here.
# The file checked here is the one `publish` attests and uploads, held
# to the sha512 above: what the catalog verifies is byte for byte what
# passed.
- name: Check the tarball as the catalog will
run: node scripts/check-package.mjs "${TARBALL}"

- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: tarball
path: ${{ env.TARBALL }}
if-no-files-found: error
retention-days: 1

publish:
name: Attest and publish
needs: build
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
# Create the release and upload the tarball.
contents: write
# The signing certificate for the attestation, and its record.
id-token: write
attestations: write
steps:
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: tarball
path: release

# The build's outputs come from a job that ran the dependencies, so they
# are read as data: through the environment, and held to their shape.
- name: Check the tarball is the one the build checked
env:
BUILT_TARBALL: ${{ needs.build.outputs.tarball }}
BUILT_SHA512: ${{ needs.build.outputs.sha512 }}
run: |
if ! [[ "${BUILT_TARBALL}" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*\.tgz$ ]]; then
echo "::error::The build named the tarball '${BUILT_TARBALL}', which is not a tarball's file name."
exit 1
fi
files="$(ls -A release)"
if [ "${files}" != "${BUILT_TARBALL}" ]; then
echo "::error::The build's artifact holds other files than ${BUILT_TARBALL} alone."
exit 1
fi
tarball="release/${BUILT_TARBALL}"
sha512="sha512-$(openssl dgst -sha512 -binary "${tarball}" | base64 -w0)"
if [ "${sha512}" != "${BUILT_SHA512}" ]; then
echo "::error::The downloaded tarball's sha512 is ${sha512}, but the build packed ${BUILT_SHA512}."
exit 1
fi
echo "TARBALL=${tarball}" >> "${GITHUB_ENV}"
echo "SHA512=${sha512}" >> "${GITHUB_ENV}"

- name: Attest the tarball
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
with:
Expand All @@ -86,31 +160,34 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
run: |
sha512="sha512-$(openssl dgst -sha512 -binary "${TARBALL}" | base64 -w0)"
# The build checked that the tag names the version in package.json.
tag="${GITHUB_REF#refs/tags/}"
bytes="$(stat -c %s "${TARBALL}")"
name="$(basename "${TARBALL}")"
{
echo "The plugin tarball for the catalog, built and attested by this repository's release workflow for \`v${VERSION}\`."
echo "The plugin tarball for the catalog, built and attested by this repository's release workflow for \`${tag}\`."
echo
echo '```'
echo "file ${name}"
echo "sha512 ${sha512}"
echo "sha512 ${SHA512}"
echo "bytes ${bytes}"
echo '```'
echo
echo "Verify it with:"
echo "Verify it as the catalog does:"
echo
echo '```'
echo "gh attestation verify ${name} --repo ${GITHUB_REPOSITORY} --source-ref refs/tags/v${VERSION} --deny-self-hosted-runners"
echo "gh attestation verify ${name} --repo ${GITHUB_REPOSITORY} --signer-workflow ${GITHUB_REPOSITORY}/.github/workflows/release.yml --source-ref refs/tags/${tag} --deny-self-hosted-runners"
echo '```'
echo
echo "What changed is in [CHANGELOG.md](https://github.com/${GITHUB_REPOSITORY}/blob/v${VERSION}/CHANGELOG.md)."
} > release/notes.md
echo "The catalog also requires the commit the attestation names to be on \`main\`."
echo
echo "What changed is in [CHANGELOG.md](https://github.com/${GITHUB_REPOSITORY}/blob/${tag}/CHANGELOG.md)."
} > notes.md
# With an asset to attach, gh creates the release as a draft,
# uploads the asset and only then publishes it, so the immutable
# release is never seen without its tarball.
gh release create "v${VERSION}" "${TARBALL}" \
gh release create "${tag}" "${TARBALL}" \
--repo "${GITHUB_REPOSITORY}" \
--verify-tag \
--title "v${VERSION}" \
--notes-file release/notes.md
--title "${tag}" \
--notes-file notes.md
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,9 +123,13 @@ The licence is MIT-0, so you can copy any of it without keeping a notice.
```

3. The release workflow checks that the tag matches the version and points at
a commit on `main`. It then builds, tests, packs, checks and attests the
tarball, and publishes the release with the tarball attached. The release
notes give the tarball's `sha512` and `bytes`.
a commit on `main`. Its `build` job then builds, tests, packs and checks
the tarball with read access only. Its `publish` job, which installs and
runs none of the lockfile's packages, holds the tarball to the `sha512` the
build packed, attests it and publishes the release with the tarball
attached. Code that runs at build time therefore never holds the token that
signs an attestation. The release notes give the tarball's `sha512` and
`bytes`, and the `gh attestation verify` command the catalog runs.

Do not create the release in the GitHub interface. That creates the tag
first, so the workflow starts after the release is already immutable, and the
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@
"typescript": "7.0.2",
"vite": "8.3.4",
"vitest": "5.0.5",
"yaml": "2.9.1",
"zod": "4.6.7"
}
}
39 changes: 25 additions & 14 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading