Skip to content

Scheduled Release

Scheduled Release #16

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