mirror of
https://github.com/FOSSBilling/FOSSBilling.git
synced 2026-11-10 14:03:30 +09:00
Reconcile the release target when updating an existing release (#4454) * Reconcile the release target when updating an existing release A pre-existing draft release (for example one created by the Release Drafter job on the default branch) kept its original target_commitish, because --target was only passed on the create path. Reconcile it on the update path too, and require target_ref to be chosen explicitly instead of defaulting to main. * Clean up comments in create-release.yml Removed comments about reconciling target for releases. * Refine target_ref description for better understanding Clarify target_ref description in create-release.yml * Refuse to attach an archive to a tag pointing at a different commit An existing tag pins the release to a commit, and both --target on create and target_commitish on update are ignored once it exists. Verify the tag's peeled commit matches the checked-out ref before uploading so a re-run against a moved branch cannot publish an artifact that does not match the tag. * Validate the release tag before uploading the archive to R2 The tag/commit check ran inside the GitHub release step, after the archive had already been mirrored to R2, so a mismatched tag could overwrite the version-specific download object before the job failed. Run it as a standalone step ahead of the R2 upload instead.
327 lines
13 KiB
YAML
327 lines
13 KiB
YAML
name: Create Release
|
|
|
|
permissions:
|
|
contents: write
|
|
|
|
on:
|
|
workflow_dispatch:
|
|
inputs:
|
|
version:
|
|
description: 'Release version, for example 0.7.3'
|
|
required: true
|
|
type: string
|
|
target_ref:
|
|
description: 'Git ref to build and tag (target)'
|
|
required: true
|
|
type: string
|
|
draft:
|
|
description: 'Create as a draft release'
|
|
required: true
|
|
default: true
|
|
type: boolean
|
|
prerelease:
|
|
description: 'Mark as a prerelease'
|
|
required: true
|
|
default: false
|
|
type: boolean
|
|
release_notes:
|
|
description: 'Release notes mode; copilot uses BYOK provider settings'
|
|
required: true
|
|
default: github
|
|
type: choice
|
|
options:
|
|
- copilot
|
|
- github
|
|
- none
|
|
|
|
concurrency:
|
|
group: create-release-${{ inputs.version }}
|
|
cancel-in-progress: false
|
|
|
|
jobs:
|
|
create-release:
|
|
name: 'Create Release'
|
|
if: ${{ github.repository == 'FOSSBilling/FOSSBilling' }}
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 60
|
|
steps:
|
|
- name: 'Validate Release Inputs'
|
|
env:
|
|
RELEASE_VERSION: ${{ inputs.version }}
|
|
TARGET_REF: ${{ inputs.target_ref }}
|
|
run: |
|
|
if [[ ! "${RELEASE_VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$ ]]; then
|
|
echo "Release version must be valid SemVer without a leading v."
|
|
exit 1
|
|
fi
|
|
|
|
if [[ ! "${TARGET_REF}" =~ ^[A-Za-z0-9._/-]+$ ]] || ! git check-ref-format --branch "${TARGET_REF}" >/dev/null 2>&1; then
|
|
echo "Target ref must be a valid branch name containing only letters, numbers, dots, underscores, slashes, or hyphens."
|
|
exit 1
|
|
fi
|
|
|
|
- name: 'Checkout Repository'
|
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
|
with:
|
|
fetch-depth: 0
|
|
ref: ${{ inputs.target_ref }}
|
|
persist-credentials: false
|
|
|
|
- name: 'Prepare Release Notes Context'
|
|
id: notes-context
|
|
if: ${{ inputs.release_notes != 'none' }}
|
|
env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
RELEASE_VERSION: ${{ inputs.version }}
|
|
REPOSITORY: ${{ github.repository }}
|
|
run: |
|
|
mkdir -p release-notes-context
|
|
|
|
previous_tag="$(
|
|
git tag --merged HEAD --sort=-v:refname \
|
|
| grep -Ev "^${RELEASE_VERSION}$" \
|
|
| head -n 1
|
|
)"
|
|
|
|
if [[ -z "${previous_tag}" ]]; then
|
|
echo "Could not determine a previous tag to diff from."
|
|
exit 1
|
|
fi
|
|
|
|
echo "previous_tag=${previous_tag}" >> "${GITHUB_OUTPUT}"
|
|
|
|
gh api "repos/${REPOSITORY}/releases" \
|
|
--jq '.[0:5] | map({tag_name, name, draft, prerelease, published_at, body})' \
|
|
> release-notes-context/recent-releases.json
|
|
|
|
if gh api "repos/${REPOSITORY}/releases/tags/${RELEASE_VERSION}" > release-notes-context/current-release.json 2>/dev/null; then
|
|
jq -r '.body // ""' release-notes-context/current-release.json > release-notes-context/current-release-notes.md
|
|
else
|
|
printf '{}\n' > release-notes-context/current-release.json
|
|
printf '\n' > release-notes-context/current-release-notes.md
|
|
fi
|
|
|
|
git log --reverse --date=short \
|
|
--pretty=format:'%h %ad %an%n%s%n%b%n---END-COMMIT---' \
|
|
"${previous_tag}..HEAD" \
|
|
> release-notes-context/git-log.txt
|
|
|
|
gh api "repos/${REPOSITORY}/releases/generate-notes" \
|
|
-f tag_name="${RELEASE_VERSION}" \
|
|
-f target_commitish="$(git rev-parse HEAD)" \
|
|
-f previous_tag_name="${previous_tag}" \
|
|
--jq '.body' \
|
|
> release-notes-context/github-generated-notes.md
|
|
|
|
- name: 'Generate GitHub Release Notes'
|
|
if: ${{ inputs.release_notes == 'github' }}
|
|
run: cp release-notes-context/github-generated-notes.md release-notes.md
|
|
|
|
- name: 'Set up Node.js for Copilot CLI'
|
|
if: ${{ inputs.release_notes == 'copilot' }}
|
|
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
|
with:
|
|
node-version: 24
|
|
cache: 'npm'
|
|
- name: 'Install Copilot CLI'
|
|
if: ${{ inputs.release_notes == 'copilot' }}
|
|
run: npm install -g @github/copilot@1.0.56
|
|
|
|
- name: 'Generate Curated Release Notes'
|
|
if: ${{ inputs.release_notes == 'copilot' }}
|
|
env:
|
|
COPILOT_MODEL: ${{ vars.COPILOT_MODEL }}
|
|
COPILOT_OFFLINE: true
|
|
COPILOT_PROVIDER_API_KEY: ${{ secrets.COPILOT_PROVIDER_API_KEY }}
|
|
COPILOT_PROVIDER_BASE_URL: ${{ vars.COPILOT_PROVIDER_BASE_URL }}
|
|
COPILOT_PROVIDER_TYPE: ${{ vars.COPILOT_PROVIDER_TYPE }}
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
RELEASE_VERSION: ${{ inputs.version }}
|
|
PREVIOUS_TAG: ${{ steps.notes-context.outputs.previous_tag }}
|
|
REPOSITORY: ${{ github.repository }}
|
|
run: |
|
|
if [[ -z "${COPILOT_PROVIDER_BASE_URL}" || -z "${COPILOT_MODEL}" ]]; then
|
|
echo "The copilot release notes mode requires COPILOT_PROVIDER_BASE_URL and COPILOT_MODEL Actions variables."
|
|
exit 1
|
|
fi
|
|
|
|
cat > release-notes-context/prompt.md <<EOF
|
|
Generate polished FOSSBilling release notes in release-notes.md.
|
|
|
|
Release version: ${RELEASE_VERSION}
|
|
Previous tag: ${PREVIOUS_TAG}
|
|
|
|
Inputs are in release-notes-context/:
|
|
- release-notes-guidelines.md: project release-note style and section rules.
|
|
- recent-releases.json: the five most recent releases or drafts for tone and structure.
|
|
- current-release-notes.md: existing notes for this release or draft, if any.
|
|
- github-generated-notes.md: GitHub's generated baseline for this tag range.
|
|
- git-log.txt: commits from the previous tag to the target revision.
|
|
|
|
Requirements:
|
|
- Diff from ${PREVIOUS_TAG} to ${RELEASE_VERSION}.
|
|
- Use existing current-release-notes.md as context, but rewrite it if needed.
|
|
- Follow release-notes-guidelines.md.
|
|
- Preserve accurate PR numbers, issue references, and security-sensitive wording.
|
|
- Group user-facing changes by the guideline sections.
|
|
- Remove duplicates and omit internal noise that does not help users upgrade or understand the release.
|
|
- Do not invent changes.
|
|
- Write only the final Markdown body to release-notes.md.
|
|
EOF
|
|
|
|
cp .github/release-notes-guidelines.md release-notes-context/release-notes-guidelines.md
|
|
|
|
copilot -p "$(cat release-notes-context/prompt.md)" \
|
|
--allow-tool='shell(cat:*)' \
|
|
--allow-tool=write \
|
|
--no-ask-user
|
|
|
|
- name: 'Set Registry Cache Image'
|
|
run: echo "CACHE_IMAGE=ghcr.io/${GITHUB_REPOSITORY@L}-build-cache" >> "${GITHUB_ENV}"
|
|
|
|
- name: 'Set up Docker Buildx'
|
|
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
|
|
|
|
- name: 'Build Release Artifact'
|
|
id: build
|
|
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
|
|
with:
|
|
build-args: |
|
|
FOSSBILLING_VERSION=${{ inputs.version }}
|
|
SENTRY_DSN=${{ vars.SENTRY_DSN }}
|
|
cache-from: type=registry,ref=${{ env.CACHE_IMAGE }}:cache-release-amd64
|
|
context: .
|
|
target: release-artifact
|
|
outputs: type=local,dest=./release-tree
|
|
provenance: false
|
|
|
|
- name: 'Create Release Archive'
|
|
id: archive
|
|
env:
|
|
RELEASE_VERSION: ${{ inputs.version }}
|
|
run: |
|
|
mkdir -p ../dist
|
|
zip -qr "../dist/FOSSBilling-${RELEASE_VERSION}.zip" .
|
|
|
|
echo "FOSSBILLING_RELEASE_DIGEST=sha256:$(sha256sum "../dist/FOSSBilling-${RELEASE_VERSION}.zip" | awk '{print $1}')" >> "$GITHUB_ENV"
|
|
working-directory: ./release-tree
|
|
|
|
- name: 'Validate Release Tag'
|
|
env:
|
|
RELEASE_VERSION: ${{ inputs.version }}
|
|
run: |
|
|
# An existing tag pins the release commit; refuse a mismatched archive.
|
|
if tag_commit="$(git rev-parse --verify --quiet "refs/tags/${RELEASE_VERSION}^{commit}")"; then
|
|
head_commit="$(git rev-parse HEAD)"
|
|
if [[ "${tag_commit}" != "${head_commit}" ]]; then
|
|
echo "Tag ${RELEASE_VERSION} points at ${tag_commit}, but this run built from ${head_commit}. Re-run with target_ref set to the tag or the matching commit."
|
|
exit 1
|
|
fi
|
|
fi
|
|
|
|
- name: 'Upload Release Archive to R2'
|
|
id: r2-upload
|
|
# Mirrors the release archive to our own IPv6-reachable storage, alongside the
|
|
# GitHub release asset. github.com and api.github.com have no AAAA record, so
|
|
# installs on IPv6-only hosts cannot download an update from GitHub at all -
|
|
# see https://github.com/FOSSBilling/FOSSBilling/issues/2479. This mirror is
|
|
# what api.fossbilling.net's `download_url` should point update clients to.
|
|
env:
|
|
RELEASE_VERSION: ${{ inputs.version }}
|
|
AWS_ACCESS_KEY_ID: ${{ secrets.CF_R2_ACCESS_KEY_ID }}
|
|
AWS_SECRET_ACCESS_KEY: ${{ secrets.CF_R2_SECRET_ACCESS_KEY }}
|
|
AWS_S3_ENDPOINT: ${{ vars.CF_R2_ENDPOINT_URL }}
|
|
AWS_S3_BUCKET: ${{ vars.CF_R2_DOWNLOAD_BUCKET }}
|
|
AWS_REGION: 'auto'
|
|
run: |
|
|
asset="./dist/FOSSBilling-${RELEASE_VERSION}.zip"
|
|
key="releases/${RELEASE_VERSION}/FOSSBilling-${RELEASE_VERSION}.zip"
|
|
|
|
aws s3 cp "${asset}" "s3://${AWS_S3_BUCKET}/${key}" \
|
|
--endpoint-url "${AWS_S3_ENDPOINT}" \
|
|
--metadata="digest=${FOSSBILLING_RELEASE_DIGEST},version=${RELEASE_VERSION}" \
|
|
--no-progress
|
|
|
|
echo "url=https://download.fossbilling.org/${key}" >> "${GITHUB_OUTPUT}"
|
|
|
|
- name: 'Create or Update GitHub Release'
|
|
id: release
|
|
env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
RELEASE_VERSION: ${{ inputs.version }}
|
|
TARGET_REF: ${{ inputs.target_ref }}
|
|
IS_DRAFT: ${{ inputs.draft }}
|
|
IS_PRERELEASE: ${{ inputs.prerelease }}
|
|
RELEASE_NOTES: ${{ inputs.release_notes }}
|
|
REPOSITORY: ${{ github.repository }}
|
|
run: |
|
|
asset="./dist/FOSSBilling-${RELEASE_VERSION}.zip"
|
|
|
|
if gh release view "${RELEASE_VERSION}" --repo "${REPOSITORY}" >/dev/null 2>&1; then
|
|
gh release upload "${RELEASE_VERSION}" "${asset}" \
|
|
--repo "${REPOSITORY}" \
|
|
--clobber
|
|
|
|
edit_args=(
|
|
"${RELEASE_VERSION}"
|
|
--repo "${REPOSITORY}"
|
|
--target "${TARGET_REF}"
|
|
)
|
|
|
|
if [[ "${RELEASE_NOTES}" != "none" ]]; then
|
|
edit_args+=(--notes-file release-notes.md)
|
|
fi
|
|
|
|
gh release edit "${edit_args[@]}"
|
|
|
|
echo "operation=updated" >> "${GITHUB_OUTPUT}"
|
|
exit 0
|
|
fi
|
|
|
|
release_args=(
|
|
"${RELEASE_VERSION}"
|
|
"${asset}"
|
|
--repo "${REPOSITORY}"
|
|
--title "${RELEASE_VERSION}"
|
|
--target "${TARGET_REF}"
|
|
)
|
|
|
|
if [[ "${RELEASE_NOTES}" != "none" ]]; then
|
|
release_args+=(--notes-file release-notes.md)
|
|
else
|
|
release_args+=(--notes "")
|
|
fi
|
|
|
|
if [[ "${IS_DRAFT}" == "true" ]]; then
|
|
release_args+=(--draft)
|
|
fi
|
|
|
|
if [[ "${IS_PRERELEASE}" == "true" ]]; then
|
|
release_args+=(--prerelease)
|
|
fi
|
|
|
|
gh release create "${release_args[@]}"
|
|
echo "operation=created" >> "${GITHUB_OUTPUT}"
|
|
|
|
- name: 'Write Job Summary'
|
|
if: always()
|
|
run: |
|
|
cat >> "${GITHUB_STEP_SUMMARY}" <<'EOF'
|
|
## Create Release
|
|
|
|
| Item | Value |
|
|
| --- | --- |
|
|
| Version | `${{ inputs.version }}` |
|
|
| Target ref | `${{ inputs.target_ref }}` |
|
|
| Draft | `${{ inputs.draft }}` |
|
|
| Prerelease | `${{ inputs.prerelease }}` |
|
|
| Release notes | `${{ inputs.release_notes }}` |
|
|
| Previous tag | `${{ steps.notes-context.outputs.previous_tag || 'n/a' }}` |
|
|
| Archive | `FOSSBilling-${{ inputs.version }}.zip` |
|
|
| Digest | `${{ env.FOSSBILLING_RELEASE_DIGEST }}` |
|
|
| Docker build | `${{ steps.build.outcome }}` |
|
|
| Archive creation | `${{ steps.archive.outcome }}` |
|
|
| R2 mirror | `${{ steps.r2-upload.outputs.url }}` |
|
|
| Release operation | `${{ steps.release.outputs.operation || steps.release.outcome }}` |
|
|
EOF
|