diff --git a/.github/workflows/bump-version.yaml b/.github/workflows/bump-version.yaml index e87fe0a..baab874 100644 --- a/.github/workflows/bump-version.yaml +++ b/.github/workflows/bump-version.yaml @@ -7,16 +7,32 @@ on: description: The new version of the interface package and crates, e.g. 0.1.0 or 0.2.0-dev required: true type: string - default: "0.1.0-dev" # the current version, kept current by scripts/bump-interface-version.sh + default: "0.1.0-dev" # the current version, kept current by scripts/bump-version.sh jobs: - # bumps the version with read only access, the changes are handed to the pull-request job as a - # patch so the third party actions used to build never run with write access + # bumps the version with read only access, the changes are handed to the push job as a patch so + # the third party actions used to build never run with write access bump: runs-on: ubuntu-latest permissions: contents: read steps: + - name: Check version + # a semver version without a leading `v`, the tag adds it. Build metadata is used to tag the + # debug builds of components, e.g. 0.1.0+debug, and isn't allowed in the version itself. + run: | + identifier='(0|[1-9][0-9]*|[0-9]*[a-zA-Z-][0-9a-zA-Z-]*)' + semver="^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-${identifier}(\.${identifier})*)?$" + if ! [[ "${VERSION}" =~ ${semver} ]] ; then + echo "::error::the version '${VERSION}' is not a valid semver version, e.g. 0.1.0 or 0.2.0-dev" + exit 1 + fi + if [[ "${VERSION}" == *+* ]] ; then + echo "::error::the version '${VERSION}' must not contain build metadata" + exit 1 + fi + env: + VERSION: ${{ inputs.version }} - uses: actions/checkout@v7 with: persist-credentials: false @@ -29,7 +45,7 @@ jobs: echo "${PWD}/target/tools/bin" >> "${GITHUB_PATH}" - name: Bump version # also fetches the wit dependencies for the new version, and builds and tests the components - run: scripts/bump-interface-version.sh "${VERSION}" + run: scripts/bump-version.sh "${VERSION}" env: VERSION: ${{ inputs.version }} - name: Collect changes @@ -44,13 +60,18 @@ jobs: if-no-files-found: error retention-days: 1 - # opens the pull request using only first party actions and the gh cli - pull-request: + # pushes the bump to the branch the workflow was run on, using only first party actions and the gh + # cli + push: needs: - bump + # the bump is a new commit on the branch, a tag can't be moved forward + if: github.ref_type == 'branch' runs-on: ubuntu-latest - # the branch and pull request are created with a token for the custodian GitHub App rather than - # the GITHUB_TOKEN, which can't change workflow files and doesn't trigger the CI workflow + # the commit is pushed with a token for the custodian GitHub App rather than the GITHUB_TOKEN, + # which can't change workflow files and doesn't trigger the CI workflow. The app must be allowed + # to bypass the branch's ruleset, the commit is pushed without the status checks it requires, + # the bump job built and tested the changes instead. permissions: contents: read env: @@ -74,9 +95,10 @@ jobs: # only this repository, with only the permissions the bump needs repositories: ${{ github.event.repository.name }} permission-contents: write - permission-pull-requests: write # the bump changes the default version in this workflow permission-workflows: write + # a release runs this workflow again, to bump to the next dev version + permission-actions: write - uses: actions/checkout@v7 with: persist-credentials: false @@ -97,7 +119,6 @@ jobs: GH_TOKEN: ${{ steps.app-token.outputs.token }} APP_SLUG: ${{ steps.app-token.outputs.app-slug }} run: | - branch="bump-version/${VERSION}" api="repos/${GITHUB_REPOSITORY}" base=$( git rev-parse HEAD ) git apply --index "${RUNNER_TEMP}/bump-version.patch" @@ -118,42 +139,65 @@ jobs: done tree=$( jq --arg base "$( git rev-parse "${base}^{tree}" )" '{base_tree: $base, tree: .}' "${entries}" | gh api --method POST "${api}/git/trees" --input - --jq .sha ) - # authored and signed off (DCO) by the user who triggered the workflow, with their GitHub - # noreply email so the commit is attributed to them without exposing their email address. - # Committed by the custodian app's bot, which made the commit on their behalf. The commit is - # unsigned, GitHub only signs commits it attributes entirely to the app. - name=$( gh api "users/${GITHUB_ACTOR}" --jq '.name // .login' ) - name="${name:-${GITHUB_ACTOR}}" - email="${GITHUB_ACTOR_ID}+${GITHUB_ACTOR}@users.noreply.github.com" + # authored, committed and signed off (DCO) by the custodian app's bot. GitHub only signs + # commits it attributes entirely to the app, so the author and committer are left for GitHub + # to fill in. The user who triggered the workflow is credited as a co-author, with their + # GitHub noreply email so their email address isn't exposed. A run started by the app after a + # release has no one else to credit. bot="${APP_SLUG}[bot]" bot_email="$( gh api "users/${bot}" --jq .id )+${bot}@users.noreply.github.com" - commit=$( jq -n \ - --arg message "$( printf 'Bump version from %s to %s\n\nSigned-off-by: %s <%s>' "${CURRENT_VERSION}" "${VERSION}" "${name}" "${email}" )" \ - --arg tree "${tree}" --arg parent "${base}" --arg name "${name}" --arg email "${email}" \ - --arg bot "${bot}" --arg bot_email "${bot_email}" \ - '{message: $message, tree: $tree, parents: [$parent], author: {name: $name, email: $email}, committer: {name: $bot, email: $bot_email}}' \ - | gh api --method POST "${api}/git/commits" --input - --jq .sha ) + message=$( printf 'Bump version from %s to %s\n\nSigned-off-by: %s <%s>' "${CURRENT_VERSION}" "${VERSION}" "${bot}" "${bot_email}" ) + if [ "${GITHUB_ACTOR}" != "${bot}" ] ; then + name=$( gh api "users/${GITHUB_ACTOR}" --jq '.name // .login' ) + name="${name:-${GITHUB_ACTOR}}" + email="${GITHUB_ACTOR_ID}+${GITHUB_ACTOR}@users.noreply.github.com" + message=$( printf '%s\nCo-authored-by: %s <%s>' "${message}" "${name}" "${email}" ) + fi + read -r commit verified < <( jq -n \ + --arg message "${message}" \ + --arg tree "${tree}" --arg parent "${base}" \ + '{message: $message, tree: $tree, parents: [$parent]}' \ + | gh api --method POST "${api}/git/commits" --input - --jq '"\(.sha) \(.verification.verified)"' ) echo "created commit ${commit}" + # the branch requires signed commits, fail before pushing rather than after + if [ "${verified}" != "true" ] ; then + echo "::error::the commit '${commit}' was not created, or was not signed by GitHub" + exit 1 + fi - # points the branch at the commit, replacing the branch left by an earlier run for the same version - if gh api "${api}/git/ref/heads/${branch}" --silent 2> /dev/null ; then - gh api --method PATCH "${api}/git/refs/heads/${branch}" -f sha="${commit}" -F force=true --silent - else - gh api --method POST "${api}/git/refs" -f ref="refs/heads/${branch}" -f sha="${commit}" --silent + # a release, rather than a pre-release, is tagged, e.g. v0.1.0. Tags are immutable, check the + # tag is free before pushing the branch, so a release isn't left without its tag + tag="" + if [[ "${VERSION}" != *-* ]] ; then + tag="v${VERSION}" + if gh api "${api}/git/ref/tags/${tag}" --silent 2> /dev/null ; then + echo "::error::the tag ${tag} already exists" + exit 1 + fi + fi + + # fast forwards the branch to the commit, failing rather than losing commits pushed to the + # branch since the workflow started + if ! gh api --method PATCH "${api}/git/refs/heads/${GITHUB_REF_NAME}" -f sha="${commit}" -F force=false --silent ; then + echo "::error::failed to push ${commit} to ${GITHUB_REF_NAME}, either the branch moved since ${base} and the workflow should be run again, or the custodian app is not allowed to bypass the branch's ruleset" + exit 1 fi - - name: Open pull request + echo "pushed ${commit} to ${GITHUB_REF_NAME}" + + # a lightweight tag, pushed with the app's token so the CI workflow runs for it and drafts + # the release + if [ -n "${tag}" ] ; then + gh api --method POST "${api}/git/refs" -f ref="refs/tags/${tag}" -f sha="${commit}" --silent + echo "tagged ${commit} as ${tag}" + fi + - name: Bump to the next dev version + # after a release, the branch moves on to a pre-release of the next patch version, e.g. 0.1.0 + # is followed by 0.1.1-dev. Run with the app's token, the GITHUB_TOKEN can't start workflows. + if: ${{ !contains(inputs.version, '-') }} env: GH_TOKEN: ${{ steps.app-token.outputs.token }} run: | - branch="bump-version/${VERSION}" - if [ -n "$( gh pr list --head "${branch}" --state open --json number --jq '.[].number' )" ] ; then - echo "A pull request for ${branch} is already open, updated by the new commit" - exit 0 - fi - gh pr create \ - --base "${GITHUB_REF_NAME}" \ - --head "${branch}" \ - --title "Bump version from \`${CURRENT_VERSION}\` to \`${VERSION}\`" \ - --body "Bumps the wit package and crates from \`${CURRENT_VERSION}\` to \`${VERSION}\`. - - Triggered by @${GITHUB_ACTOR} from the [Bump version](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}) workflow." + IFS=. read -r major minor patch <<< "${VERSION}" + next="${major}.${minor}.$(( patch + 1 ))-dev" + gh workflow run bump-version.yaml --repo "${GITHUB_REPOSITORY}" --ref "${GITHUB_REF_NAME}" -f version="${next}" + echo "started the ${GITHUB_WORKFLOW} workflow for ${next} on ${GITHUB_REF_NAME}" diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index df0bc3d..32e030b 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -4,7 +4,6 @@ on: push: branches: - "**" - - "!bump-version/**" - "!dependabot/**" tags: - 'v[0-9]+\.[0-9]+\.[0-9]+-?**' @@ -28,12 +27,12 @@ jobs: run: git diff --exit-code . - name: Check crate version matches the interface version # the crates inherit the workspace version, bumped with the interface by - # scripts/bump-interface-version.sh + # scripts/bump-version.sh run: | interface_version=$( sed -n "s/^package ${GITHUB_REPOSITORY/\//:}@\(.*\);$/\1/p" wit/worlds.wit ) workspace_version=$( sed -n '/^\[workspace.package\]/,/^\[/s/^version = "\(.*\)"$/\1/p' Cargo.toml ) if [ "${workspace_version}" != "${interface_version}" ] ; then - echo "::error::the workspace version ${workspace_version} in Cargo.toml does not match the interface version ${interface_version}, see scripts/bump-interface-version.sh" + echo "::error::the workspace version ${workspace_version} in Cargo.toml does not match the interface version ${interface_version}, see scripts/bump-version.sh" exit 1 fi - name: Build components @@ -198,16 +197,29 @@ jobs: if: startsWith(github.ref, 'refs/tags/') || (github.ref == 'refs/heads/main' && contains(steps.interface_version.outputs.VERSION, '-')) run: make publish env: + PUBLISH_LOG: "${{ runner.temp }}/published.txt" REPOSITORY: "ghcr.io/${{ github.repository }}" VERSION: "${{ case(github.ref == 'refs/heads/main', steps.interface_version.outputs.VERSION, steps.tag_version.outputs.VERSION) }}" + - name: Draft release notes + if: startsWith(github.ref, 'refs/tags/') + run: | + { + echo "## Published components" + echo + echo "| File | Image |" + echo "| --- | --- |" + while read -r file image ; do + echo "| \`${file}\` | \`${image}\` |" + done < "${RUNNER_TEMP}/published.txt" + } > "${RUNNER_TEMP}/release-notes.md" + cat "${RUNNER_TEMP}/release-notes.md" - name: Draft GitHub Release if: startsWith(github.ref, 'refs/tags/') uses: softprops/action-gh-release@v3 with: draft: true + body_path: ${{ runner.temp }}/release-notes.md files: | - target/components/*.wasm - target/components/*/*.wasm components.tar dist/* fail_on_unmatched_files: true diff --git a/Cargo.toml b/Cargo.toml index f4a8a2e..b34f177 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,7 +35,7 @@ members = [ ] # the version of the cli and library crates, kept in step with the interface package by -# scripts/bump-interface-version.sh +# scripts/bump-version.sh [workspace.package] version = "0.1.0-dev" diff --git a/Makefile b/Makefile index b67b1d3..2c1487b 100644 --- a/Makefile +++ b/Makefile @@ -166,13 +166,6 @@ ${COMPONENTS_DIR}/interface.wasm: wit/deps README.md | $(call tool,wkg) .PHONY: wit wit: wit/deps components/wit/deps -.PHONY: bump-interface-version ## Bump the interface package and crate versions, e.g. INTERFACE_VERSION=0.1.0 -bump-interface-version: -ifndef INTERFACE_VERSION - $(error INTERFACE_VERSION is undefined) -endif - scripts/bump-interface-version.sh $(INTERFACE_VERSION) - # the wit dependencies are fetched rather than committed, see .gitignore wit/deps: wkg.toml $(shell find wit -type f -name "*.wit" -not -path "*/deps/*") | $(call tool,wkg) $(WKG) fetch --config $(WKG_CONFIG) @@ -182,6 +175,8 @@ components/wit/deps: wit/deps components/wkg.toml $(shell find components/wit -t # sign published components with cosign, `SIGN=false` to push without signing, e.g. to a local registry SIGN ?= true +# append each published file and its image to this file, e.g. `factory.wasm ghcr.io/componentized/constants/factory:0.1.0@sha256:...` +PUBLISH_LOG ?= # the files that can be published, e.g. gate.wasm, published from target/components/gate/gate.wasm PUBLISH_FILES := interface.wasm $(foreach component,$(filter-out dep-% test-%,$(COMPONENTS)),$(component).wasm $(component).debug.wasm) @@ -191,49 +186,5 @@ publish: $(addprefix publish-,$(PUBLISH_FILES)) .PHONY: $(addprefix publish-,$(PUBLISH_FILES)) $(addprefix publish-,$(PUBLISH_FILES)): publish-%: | $(call tool,wkg) -ifndef VERSION - $(error VERSION is undefined) -endif -ifndef REPOSITORY - $(error REPOSITORY is undefined) -endif - @$(eval FILE := $(@:publish-%=%)) - @$(eval COMPONENT := $(patsubst %.wasm,%,$(patsubst %.debug.wasm,%,$(FILE)))) -# components are in a directory of their own, the interface is not, e.g. gate/gate.wasm and interface.wasm - @$(eval COMPONENT_FILE := $(if $(filter interface.wasm,$(FILE)),$(FILE),$(COMPONENT)/$(FILE))) - @$(eval README := ${COMPONENTS_DIR}/$(dir $(COMPONENT_FILE))README.md) - @$(eval TITLE := $(if $(filter %.debug.wasm,$(FILE)),$(COMPONENT) (debug),$(COMPONENT))) - @$(eval DESCRIPTION := $(shell head -n 3 "$(README)" | tail -n 1)) - @$(eval REVISION := $(shell git rev-parse HEAD)$(shell git diff --quiet HEAD || echo "+dirty")) - @$(eval COMPONENT_VERSION := $(if $(filter %.debug.wasm,$(FILE)),${VERSION}+debug,${VERSION})) - @$(eval TAG := $(patsubst v%,%,$(subst +,_,$(COMPONENT_VERSION)))) - @$(eval IMAGE := $(if $(filter interface.wasm,$(FILE)),${REPOSITORY}:${TAG},${REPOSITORY}/${COMPONENT}:${TAG})) - -# a debug build identical to the release build adds nothing, e.g. components without debug info - @$(eval SKIP := $(if $(filter %.debug.wasm,$(FILE)),$(shell cmp -s "${COMPONENTS_DIR}/${COMPONENT_FILE}" "${COMPONENTS_DIR}/${COMPONENT}/${COMPONENT}.wasm" && echo true))) - -# a failed push is recorded rather than stopping make, so the group is always closed before failing - @$(eval FAILED := ${COMPONENTS_DIR}/.publish-${FILE}.failed) - @rm -f "${FAILED}" - - @$(if $(SKIP),echo "Not publishing ${FILE} as it is identical to ${COMPONENT}.wasm",echo "::group::${FILE} -> ${IMAGE}") - @$(if $(SKIP),exit 0 ;) \ - set -o pipefail ; \ - DIGEST=$$( \ - $(WKG) oci push \ - --annotation "org.opencontainers.image.title=${TITLE}" \ - --annotation "org.opencontainers.image.description=${DESCRIPTION}" \ - --annotation "org.opencontainers.image.version=${COMPONENT_VERSION}" \ - --annotation "org.opencontainers.image.source=https://github.com/${GITHUB_REPOSITORY}.git" \ - --annotation "org.opencontainers.image.revision=${REVISION}" \ - --annotation "org.opencontainers.image.licenses=Apache-2.0" \ - "${IMAGE}" \ - "${COMPONENTS_DIR}/${COMPONENT_FILE}" \ - 2>&1 \ - | tee /dev/stderr \ - | grep -o 'sha256:[a-f0-9]\{64\}' \ - ) && \ - $(if $(filter true,$(SIGN)),cosign sign --yes "${IMAGE}@$${DIGEST}",echo "Not signing ${IMAGE}@$${DIGEST}, SIGN=${SIGN}") \ - || touch "${FAILED}" - @$(if $(SKIP),,echo "::endgroup::") - @if [ -f "${FAILED}" ] ; then rm -f "${FAILED}" ; echo "Failed to publish ${FILE}" >&2 ; exit 1 ; fi + @VERSION="$(VERSION)" REPOSITORY="$(REPOSITORY)" COMPONENTS_DIR="$(COMPONENTS_DIR)" SIGN="$(SIGN)" PUBLISH_LOG="$(PUBLISH_LOG)" \ + scripts/publish.sh $* diff --git a/scripts/bump-interface-version.sh b/scripts/bump-version.sh similarity index 95% rename from scripts/bump-interface-version.sh rename to scripts/bump-version.sh index a92669f..97e80c8 100755 --- a/scripts/bump-interface-version.sh +++ b/scripts/bump-version.sh @@ -1,8 +1,8 @@ #!/usr/bin/env bash -# Bump the version of the interface package, e.g. componentized:constants, and of the crates. +# Bump the version of the wit interface package, and of the crates. # -# scripts/bump-interface-version.sh +# scripts/bump-version.sh # # Updates the package declaration and every reference to the package in tracked files, then # refreshes the generated wit dependencies. The crates share the interface's version: the @@ -62,7 +62,7 @@ fi # the bump-version workflow offers the current version as the default for the next bump, checked # before changing anything workflow=.github/workflows/bump-version.yaml -workflow_default="default: \"${old}\" # the current version, kept current by scripts/bump-interface-version.sh" +workflow_default="default: \"${old}\" # the current version, kept current by scripts/bump-version.sh" if ! grep -qF "$workflow_default" "$workflow"; then echo "unable to find the current version as the default in ${workflow}, expected: ${workflow_default}" >&2 exit 1 diff --git a/scripts/publish.sh b/scripts/publish.sh new file mode 100755 index 0000000..c01b6e7 --- /dev/null +++ b/scripts/publish.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash + +# Publish a built component to an OCI registry, and sign it with cosign. +# +# VERSION= REPOSITORY= scripts/publish.sh +# +# The file is one of the files built into the components directory, components are in a directory +# of their own, the interface is not, e.g. factory.wasm from target/components/factory/factory.wasm and +# interface.wasm from target/components/interface.wasm. The interface is published to the +# repository, each component to a repository of its own under it, a debug build is tagged with a +# `_debug` suffix. A debug build identical to the release build adds nothing, e.g. components +# without debug info, and is not published. +# +# interface.wasm -> ${REPOSITORY}:0.1.0 +# factory.wasm -> ${REPOSITORY}/factory:0.1.0 +# factory.debug.wasm -> ${REPOSITORY}/factory:0.1.0_debug +# +# VERSION the version to publish, a leading `v` is dropped from the tag, e.g. v0.1.0 +# REPOSITORY the repository to publish to, e.g. ghcr.io/componentized/constants +# GITHUB_REPOSITORY the GitHub repository the components are built from, e.g. componentized/constants +# COMPONENTS_DIR the directory the components are built into, defaults to target/components +# SIGN sign the published components with cosign, `false` to push without signing, +# e.g. to a local registry +# PUBLISH_LOG append the published file and its image to this file, e.g. +# `factory.wasm ghcr.io/componentized/constants/factory:0.1.0@sha256:...` + +set -euo pipefail + +cd "$(dirname "$0")/.." + +file="${1:-}" +if [[ -z "$file" ]]; then + echo "usage: $0 , e.g. interface.wasm or factory.wasm" >&2 + exit 1 +fi +: "${VERSION:?VERSION is undefined}" +: "${REPOSITORY:?REPOSITORY is undefined}" +GITHUB_REPOSITORY="${GITHUB_REPOSITORY:-componentized/$(basename "$(git rev-parse --show-toplevel)")}" +COMPONENTS_DIR="${COMPONENTS_DIR:-target/components}" +SIGN="${SIGN:-true}" +PUBLISH_LOG="${PUBLISH_LOG:-}" + +component="${file%.wasm}" +component="${component%.debug}" +debug="" +[[ "$file" == *.debug.wasm ]] && debug=true + +if [[ "$file" == interface.wasm ]]; then + component_file="$file" + title="${GITHUB_REPOSITORY/\//:}" +else + component_file="${component}/${file}" + title="${GITHUB_REPOSITORY/\//:}-${component}" +fi +[[ -n "$debug" ]] && title="${title} (debug)" + +if [[ -n "$debug" ]] && cmp -s "${COMPONENTS_DIR}/${component_file}" "${COMPONENTS_DIR}/${component}/${component}.wasm"; then + echo "Not publishing ${file} as it is identical to ${component}.wasm" + exit 0 +fi + +# the description is the line following the title in the readme +readme="${COMPONENTS_DIR}/$(dirname "$component_file")/README.md" +description=$(sed -n 3p "$readme") + +commit=$(git rev-parse HEAD) +revision="$commit" +git diff --quiet HEAD || revision="${commit}+dirty" +url="https://github.com/${GITHUB_REPOSITORY}/tree/${commit}" +[[ -f "components/${component}/README.md" ]] && url="${url}/components/${component}" + +component_version="$VERSION" +[[ -n "$debug" ]] && component_version="${VERSION}+debug" +tag="${component_version#v}" +tag="${tag//+/_}" + +if [[ "$file" == interface.wasm ]]; then + image="${REPOSITORY}:${tag}" +else + image="${REPOSITORY}/${component}:${tag}" +fi + +echo "::group::${file} -> ${image}" +# close the group when the push or signing fails, so the error isn't hidden in it +trap 'echo "::endgroup::"' EXIT + +digest=$( + wkg oci push \ + --annotation "org.opencontainers.image.title=${title}" \ + --annotation "org.opencontainers.image.description=${description}" \ + --annotation "org.opencontainers.image.version=${component_version}" \ + --annotation "org.opencontainers.image.url=${url}" \ + --annotation "org.opencontainers.image.source=https://github.com/${GITHUB_REPOSITORY}.git" \ + --annotation "org.opencontainers.image.revision=${revision}" \ + --annotation "org.opencontainers.image.licenses=Apache-2.0" \ + "$image" \ + "${COMPONENTS_DIR}/${component_file}" \ + 2>&1 \ + | tee /dev/stderr \ + | grep -o 'sha256:[a-f0-9]\{64\}' +) + +if [[ -n "$PUBLISH_LOG" ]]; then + echo "${file} ${image}@${digest}" >> "$PUBLISH_LOG" +fi + +if [[ "$SIGN" == true ]]; then + cosign sign --yes "${image}@${digest}" +else + echo "Not signing ${image}@${digest}, SIGN=${SIGN}" +fi