Scheduled Release #16
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Scheduled Release | |
| on: | |
| workflow_dispatch: | |
| schedule: | |
| - cron: "0 19 * * *" | |
| permissions: | |
| contents: write | |
| env: | |
| SING_BOX_VERSION: 1.13.12 | |
| jobs: | |
| precheck: | |
| name: Compute release metadata | |
| runs-on: ubuntu-latest | |
| outputs: | |
| should_skip: ${{ steps.meta.outputs.should_skip }} | |
| next_version: ${{ steps.meta.outputs.next_version }} | |
| release_notes: ${{ steps.meta.outputs.release_notes }} | |
| steps: | |
| - name: Compute version and release notes | |
| id: meta | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| const currentSha = context.sha.toLowerCase(); | |
| const { owner, repo } = context.repo; | |
| const releases = await github.paginate(github.rest.repos.listReleases, { | |
| owner, | |
| repo, | |
| per_page: 100 | |
| }); | |
| const stableReleases = releases.filter((release) => !release.prerelease && !release.draft); | |
| const latestStable = stableReleases[0] || null; | |
| if (latestStable) { | |
| const body = latestStable.body || ""; | |
| const match = body.match(/Commit:\s*([0-9a-f]{40})/i); | |
| if (match && match[1].toLowerCase() === currentSha) { | |
| core.info("current commit already released"); | |
| core.setOutput("should_skip", "true"); | |
| core.setOutput("next_version", latestStable.tag_name || ""); | |
| core.setOutput("release_notes", ""); | |
| return; | |
| } | |
| } | |
| function parseVersion(tag) { | |
| const match = String(tag || "").match(/^(\d+)\.(\d+)\.(\d+)$/); | |
| if (!match) { | |
| return null; | |
| } | |
| return { | |
| major: Number(match[1]), | |
| minor: Number(match[2]), | |
| patch: Number(match[3]) | |
| }; | |
| } | |
| let highest = { major: 0, minor: 0, patch: 0 }; | |
| let hasSemver = false; | |
| for (const release of stableReleases) { | |
| const parsed = parseVersion(release.tag_name); | |
| if (!parsed) { | |
| continue; | |
| } | |
| hasSemver = true; | |
| const isHigher = | |
| parsed.major > highest.major || | |
| (parsed.major === highest.major && parsed.minor > highest.minor) || | |
| (parsed.major === highest.major && parsed.minor === highest.minor && parsed.patch > highest.patch); | |
| if (isHigher) { | |
| highest = parsed; | |
| } | |
| } | |
| let nextVersion = "0.0.1"; | |
| if (hasSemver) { | |
| let { major, minor, patch } = highest; | |
| patch += 1; | |
| if (patch >= 100) { | |
| patch = 0; | |
| minor += 1; | |
| } | |
| if (minor >= 100) { | |
| minor = 0; | |
| major += 1; | |
| } | |
| nextVersion = `${major}.${minor}.${patch}`; | |
| } | |
| let releaseNotes = "- Initial release"; | |
| if (latestStable) { | |
| try { | |
| const compare = await github.rest.repos.compareCommits({ | |
| owner, | |
| repo, | |
| base: latestStable.tag_name, | |
| head: context.sha | |
| }); | |
| const commits = compare.data.commits || []; | |
| if (commits.length > 0) { | |
| releaseNotes = commits | |
| .map((commit) => { | |
| const sha = commit.sha.slice(0, 7); | |
| const message = String(commit.commit.message || "").split("\n")[0].trim(); | |
| return `- ${sha} ${message || "update"}`; | |
| }) | |
| .join("\n"); | |
| } else { | |
| releaseNotes = "- No code changes found in compare range"; | |
| } | |
| } catch (error) { | |
| core.warning(`compare failed: ${error.message}`); | |
| releaseNotes = "- Release notes unavailable"; | |
| } | |
| } | |
| core.setOutput("should_skip", "false"); | |
| core.setOutput("next_version", nextVersion); | |
| core.setOutput("release_notes", releaseNotes); | |
| build: | |
| name: Build ${{ matrix.asset_prefix }} | |
| runs-on: ubuntu-latest | |
| needs: precheck | |
| if: needs.precheck.outputs.should_skip != 'true' | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| include: | |
| - asset_prefix: linux-amd64 | |
| goarch: amd64 | |
| gomips: "" | |
| sing_box_asset: linux-amd64 | |
| - asset_prefix: linux-arm64 | |
| goarch: arm64 | |
| gomips: "" | |
| sing_box_asset: linux-arm64 | |
| - asset_prefix: linux-mipsle | |
| goarch: mipsle | |
| gomips: softfloat | |
| sing_box_asset: linux-mipsle-softfloat | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| - name: Setup Node | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: 22 | |
| cache: npm | |
| - name: Build web assets | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| npm ci | |
| npm run build | |
| - name: Setup Go | |
| uses: actions/setup-go@v5 | |
| with: | |
| go-version-file: sboxctl/go.mod | |
| cache-dependency-path: sboxctl/go.sum | |
| - name: Build package | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| asset_prefix="${{ matrix.asset_prefix }}" | |
| stage_dir="dist/${asset_prefix}" | |
| mkdir -p "${stage_dir}" | |
| export CGO_ENABLED=0 | |
| export GOOS=linux | |
| export GOARCH="${{ matrix.goarch }}" | |
| if [ -n "${{ matrix.gomips }}" ]; then | |
| export GOMIPS="${{ matrix.gomips }}" | |
| fi | |
| cd sboxctl | |
| go build \ | |
| -trimpath \ | |
| -ldflags="-s -w -X main.Version=${{ needs.precheck.outputs.next_version }}" \ | |
| -o "../${stage_dir}/sboxctl" \ | |
| ./ | |
| cd .. | |
| tmp_dir="$(mktemp -d)" | |
| trap 'rm -rf "${tmp_dir}"' EXIT | |
| sing_box_archive="sing-box-${SING_BOX_VERSION}-${{ matrix.sing_box_asset }}.tar.gz" | |
| sing_box_url="https://github.com/SagerNet/sing-box/releases/download/v${SING_BOX_VERSION}/${sing_box_archive}" | |
| curl --fail --location --show-error --silent \ | |
| --output "${tmp_dir}/${sing_box_archive}" \ | |
| "${sing_box_url}" | |
| tar -xzf "${tmp_dir}/${sing_box_archive}" -C "${tmp_dir}" | |
| sing_box_path="$(find "${tmp_dir}" -type f -name sing-box -perm -111 | head -n 1)" | |
| if [ -z "${sing_box_path}" ]; then | |
| echo "sing-box binary not found in ${sing_box_archive}" >&2 | |
| exit 1 | |
| fi | |
| cp "${sing_box_path}" "${stage_dir}/sing-box" | |
| chmod 0755 "${stage_dir}/sing-box" | |
| tar -C dist -czf "dist/${asset_prefix}.tgz" "${asset_prefix}" | |
| - name: Upload packaged artifact | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: ${{ matrix.asset_prefix }} | |
| path: dist/${{ matrix.asset_prefix }}.tgz | |
| if-no-files-found: error | |
| release: | |
| name: Publish release | |
| runs-on: ubuntu-latest | |
| needs: | |
| - precheck | |
| - build | |
| if: needs.precheck.outputs.should_skip != 'true' | |
| steps: | |
| - name: Download artifacts | |
| uses: actions/download-artifact@v4 | |
| with: | |
| path: release-assets | |
| - name: Verify packaged assets | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| find release-assets -type f -name '*.tgz' | sort | |
| count="$(find release-assets -type f -name '*.tgz' | wc -l | tr -d ' ')" | |
| if [ "$count" -eq 0 ]; then | |
| echo "no packaged assets found" >&2 | |
| exit 1 | |
| fi | |
| - name: Publish release | |
| uses: softprops/action-gh-release@v2 | |
| with: | |
| tag_name: ${{ needs.precheck.outputs.next_version }} | |
| name: ${{ needs.precheck.outputs.next_version }} | |
| body: | | |
| Automated scheduled release. | |
| Commit: ${{ github.sha }} | |
| Changes since previous release: | |
| ${{ needs.precheck.outputs.release_notes }} | |
| prerelease: false | |
| make_latest: true | |
| files: | | |
| release-assets/**/*.tgz |