diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6917733..402e5ba 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,20 +5,11 @@ name: release # re-run at the exact tagged commit. Tag like `v0.1.0` (`git tag -s v0.1.0 -m # "v0.1.0" && git push origin v0.1.0`). # -# This app's deployable artifact is a container image run on the maintainer's -# own seedbox next to Calibre-Web (docker-compose.yml) — it is deliberately -# NOT published to a public registry (README: "self-hosted, private by -# design"; no GHCR credentials are configured for this repo). So this -# pipeline does what a release *can* mean here without inventing a publish -# target that doesn't exist yet: it re-verifies at the tag, builds the image -# and gates it on the same Trivy HIGH/CRITICAL scan container-scan.yml runs -# in CI, builds the installable sdist+wheel of the `stacks` CLI/library -# packages (ingest/recommender/app) that the container itself is built from, -# generates a CycloneDX SBOM, attests build provenance (keyless OIDC, no -# stored signing key), and creates the GitHub Release with the CHANGELOG -# section as notes. If/when a registry publish target is decided (issue -# #33), add a `packages: write` publish job after this one — don't block the -# rest of the pipeline on that undecided step. +# The release re-verifies at the tag, scans and publishes the container to +# GHCR by digest, builds the sdist+wheel, generates a CycloneDX SBOM, creates +# keyless Sigstore signatures and GitHub build-provenance attestations, creates +# the GitHub Release from the matching CHANGELOG section, then downloads and +# verifies every published artifact (issue #33). on: push: @@ -37,6 +28,7 @@ jobs: # The tag must match the package version (without the leading v) — the # single source of version truth is `pyproject.toml` (REL §2). version-check: + name: Validate release metadata runs-on: ubuntu-latest steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 @@ -52,11 +44,45 @@ jobs: exit 1 fi + - name: Require a verified signed annotated tag + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + if [ "$(git cat-file -t "refs/tags/${GITHUB_REF_NAME}")" != "tag" ]; then + echo "::error::${GITHUB_REF_NAME} must be an annotated tag" + exit 1 + fi + tag_object="$(git rev-parse "refs/tags/${GITHUB_REF_NAME}")" + verified="$(gh api "repos/${GITHUB_REPOSITORY}/git/tags/${tag_object}" --jq '.verification.verified')" + if [ "$verified" != "true" ]; then + echo "::error::${GITHUB_REF_NAME} is not a cryptographically verified signed tag" + exit 1 + fi + + - name: CHANGELOG and CITATION match the tag + run: | + set -euo pipefail + version="${GITHUB_REF_NAME#v}" + citation_version="$(awk '$1 == "version:" {print $2; exit}' CITATION.cff)" + citation_date="$(awk '$1 == "date-released:" {print $2; exit}' CITATION.cff)" + tag_date="$(git for-each-ref --format='%(taggerdate:short)' "refs/tags/${GITHUB_REF_NAME}")" + test "$citation_version" = "$version" || { + echo "::error::CITATION.cff version ${citation_version} does not match ${version}"; exit 1; + } + test "$citation_date" = "$tag_date" || { + echo "::error::CITATION.cff date-released ${citation_date} does not match tag date ${tag_date}"; exit 1; + } + grep -Fq "## [${version}]" CHANGELOG.md || { + echo "::error::CHANGELOG.md has no ## [${version}] section"; exit 1; + } + # Never publish untested code: re-run the exact merge-blocking gate # (`make verify` — lint, typecheck, test+coverage, security, a11y, eval) at # the tagged commit, not the PR's stale green check. Mirrors ci.yml's # toolchain setup exactly so local/CI/release parity holds. verify-at-tag: + name: Verify tagged commit needs: [version-check] runs-on: ubuntu-latest steps: @@ -89,7 +115,7 @@ jobs: - name: Install project (dev + app extras, locked) run: | uv sync --frozen --extra dev --extra app - npm install -g pa11y + npm install -g pa11y@9.1.1 - name: Install gitleaks (pinned v8.30.1, checksum-verified) run: | @@ -117,6 +143,7 @@ jobs: # main (container-scan.yml) — a release must not ship a HIGH/CRITICAL image # even though the image itself isn't published anywhere by this workflow. container-scan-at-tag: + name: Scan release container needs: [version-check] runs-on: ubuntu-latest permissions: @@ -142,6 +169,7 @@ jobs: format: table build: + name: Build and attest packages needs: [verify-at-tag, container-scan-at-tag] runs-on: ubuntu-latest permissions: @@ -187,12 +215,57 @@ jobs: name: dist path: dist/ + publish-image: + name: Publish and sign GHCR image + needs: [verify-at-tag, container-scan-at-tag] + runs-on: ubuntu-latest + permissions: + contents: read + packages: write # publish the release image to GHCR + id-token: write # obtain the keyless Sigstore identity + attestations: write # publish image provenance to GitHub + outputs: + image: ${{ steps.publish.outputs.image }} + digest: ${{ steps.publish.outputs.digest }} + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + - name: Install cosign (pinned) + uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 + - name: Build and publish GHCR image by digest + id: publish + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + image="ghcr.io/${GITHUB_REPOSITORY,,}" + echo "$GH_TOKEN" | docker login ghcr.io -u "$GITHUB_ACTOR" --password-stdin + docker build -t "${image}:${GITHUB_REF_NAME}" . + docker push "${image}:${GITHUB_REF_NAME}" + digest_ref="$(docker inspect --format='{{index .RepoDigests 0}}' "${image}:${GITHUB_REF_NAME}")" + test -n "$digest_ref" + echo "image=$image" >> "$GITHUB_OUTPUT" + echo "digest=${digest_ref#*@}" >> "$GITHUB_OUTPUT" + - name: Keyless-sign the image digest + env: + IMAGE: ${{ steps.publish.outputs.image }} + DIGEST: ${{ steps.publish.outputs.digest }} + run: cosign sign --yes "${IMAGE}@${DIGEST}" + - name: Attest image build provenance + uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 + with: + subject-name: ${{ steps.publish.outputs.image }} + subject-digest: ${{ steps.publish.outputs.digest }} + push-to-registry: true + # Create the GitHub Release with the matching CHANGELOG section as notes. github-release: - needs: build + name: Publish GitHub Release + needs: [build, publish-image] runs-on: ubuntu-latest permissions: - contents: write + contents: write # create the release and upload its assets steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: @@ -212,7 +285,8 @@ jobs: if [ -s /tmp/release-notes.md ]; then echo "notes extracted for $version:"; cat /tmp/release-notes.md else - echo "::warning::no CHANGELOG section found for $version; falling back to autogenerated notes" + echo "::error::no CHANGELOG section found for $version" + exit 1 fi - name: Create GitHub Release @@ -222,7 +296,45 @@ jobs: if [ -s /tmp/release-notes.md ]; then gh release create "$GITHUB_REF_NAME" dist/* \ --title "$GITHUB_REF_NAME" --notes-file /tmp/release-notes.md --verify-tag - else - gh release create "$GITHUB_REF_NAME" dist/* \ - --title "$GITHUB_REF_NAME" --generate-notes --verify-tag fi + + verify-published: + name: Verify published artifacts + needs: [build, publish-image, github-release] + runs-on: ubuntu-latest + permissions: + contents: read + packages: read # pull the just-published GHCR image by digest + steps: + - name: Set up uv (pinned) + uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0 + with: + version: "0.11.26" + enable-cache: false + - name: Install cosign (pinned) + uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 + - name: Download and validate published release assets + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + mkdir dist + gh release download "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --dir dist + test "$(find dist -maxdepth 1 -type f | wc -l)" -ge 3 + uvx twine check dist/*.whl dist/*.tar.gz + jq -e '.bomFormat == "CycloneDX" and (.components | length > 0)' dist/sbom.cdx.json >/dev/null + gh attestation verify dist/*.whl dist/*.tar.gz dist/sbom.cdx.json \ + --repo "$GITHUB_REPOSITORY" + - name: Verify and pull the signed image by digest + env: + IMAGE: ${{ needs.publish-image.outputs.image }} + DIGEST: ${{ needs.publish-image.outputs.digest }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + echo "$GH_TOKEN" | docker login ghcr.io -u "$GITHUB_ACTOR" --password-stdin + cosign verify \ + --certificate-identity-regexp "^https://github.com/${GITHUB_REPOSITORY}/.github/workflows/release.yml@refs/tags/v" \ + --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \ + "${IMAGE}@${DIGEST}" + docker pull "${IMAGE}@${DIGEST}" diff --git a/CHANGELOG.md b/CHANGELOG.md index 189a684..0d7e080 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,9 +8,9 @@ release (see `docs/ROADMAP.md` REL-01/REL-05: pre-1.0, current line `0.1.x`). ## [Unreleased] -No release has been tagged yet. `v0.1.0` is pending: the release-lifecycle pipeline -(build/sign/SBOM/publish) and the pre-release accessibility/responsible-tech sign-offs are -tracked gaps — see README §Standards and `docs/audits/accessibility-2026-06-05.md`. +No release has been tagged yet. `v0.1.0` is pending the pre-release +accessibility/responsible-tech sign-offs; the automated build, SBOM, GHCR, +keyless-signing/provenance, release, and verify-published lifecycle is in place. ### Added - Structured JSON request logging, `/livez`, and fail-closed `/readyz` (#13). diff --git a/README.md b/README.md index 988474f..5db6687 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ verified: 2026-07-11.* | CODE-QUALITY | **Applies** | ruff (incl. bandit `S` + mccabe `C90` complexity) + `mypy --strict`, both blocking; `.pre-commit-config.yaml` mirrors the fast checks locally. | | SECURITY-AND-SUPPLY-CHAIN | **Applies** | `pip-audit` (empty ignore list) + gitleaks (pinned binary in CI, `scripts/secret-scan.sh`) + Trivy container CVE scan, all merge-blocking; see `docs/audits/residual-risk.md`. | | CI-CD | **Applies** | 3 workflows, all least-privilege (`permissions: contents: read`), all `uses:` SHA-pinned. | -| RELEASE-AND-VERSIONING | **Applies — first release pending, gap tracked in [#33](https://github.com/ChelseaKR/queer-the-stacks/issues/33)** | Pre-1.0 (`0.1.x` is the current, unreleased line per `SECURITY.md`). `CHANGELOG.md` exists; the tag-triggered build/SBOM/signing pipeline does not yet — tracked, not silently missing. | +| RELEASE-AND-VERSIONING | **Applies — automated lifecycle shipped; first release pending** | Pre-1.0 (`0.1.x` is the current, unreleased line per `SECURITY.md`). Signed annotated `v*` tags trigger exact-commit verification, package/SBOM and GHCR builds, keyless signing/provenance, GitHub Release publication, and post-publication verification. | | ACCESSIBILITY | **Applies** | Zero-violation gate from **two** blocking layers — a structural checker and pa11y/axe (browser-engine, incl. color-contrast; graduated from advisory 2026-07-05). Manual review-gate walkthroughs (keyboard, screen-reader, zoom/reflow, contrast) are still pending first release — see [`docs/audits/accessibility-2026-06-05.md`](docs/audits/accessibility-2026-06-05.md). | | OBSERVABILITY | **Applies — Tier C** | Local-only, single-user, no network surface. Structured JSON logs, `/livez`, fail-closed `/readyz`, `/version` — see [`docs/ROADMAP.md` §Observability](docs/ROADMAP.md#observability) for the full per-signal N/A-with-reason declaration. | | INTERNATIONALIZATION | **N/A** — single-user, English-only personal tool (explicit opt-out permitted by the standard) | See [`docs/I18N.md`](docs/I18N.md). | diff --git a/docs/audits/coverage.xml b/docs/audits/coverage.xml index be82525..2dd2a52 100644 --- a/docs/audits/coverage.xml +++ b/docs/audits/coverage.xml @@ -1,11 +1,11 @@ - + - /private/tmp/qts-pr28/app - /private/tmp/qts-pr28/ingest - /private/tmp/qts-pr28/recommender + /private/tmp/qts-issue33/app + /private/tmp/qts-issue33/ingest + /private/tmp/qts-issue33/recommender diff --git a/docs/audits/scorecard-2026-07.md b/docs/audits/scorecard-2026-07.md index 385f9ee..e20d05f 100644 --- a/docs/audits/scorecard-2026-07.md +++ b/docs/audits/scorecard-2026-07.md @@ -32,7 +32,7 @@ UNVERIFIED from the working tree in the 2026-07-05 conformance audit into measur | 0/10 | Fuzzing | project is not fuzzed (not applicable to this app's surface today) | | 9/10 | License | license file detected | | 0/10 | Maintained | project created within the last 90 days (expected — young repo; will rise over time) | -| ? | Packaging | no packaging workflow detected (tracked — see release-lifecycle gap, issue #33) | +| ✓ | Packaging | tag-triggered package, SBOM, and GHCR release workflow is present | | 3/10 | Pinned-Dependencies | dependency not pinned by hash detected — see note above | | 0/10 | SAST | not run on all commits *(this pass adds `codeql.yml` + `zizmor.yml` — should rise once pushed)* | | 10/10 | Security-Policy | `SECURITY.md` detected |