Skip to content

Merge branch 'develop' of github.com:taylorcox75/qRemote #55

Merge branch 'develop' of github.com:taylorcox75/qRemote

Merge branch 'develop' of github.com:taylorcox75/qRemote #55

Workflow file for this run

name: iOS App Store Deploy
on:
push:
branches:
- main
paths:
- 'package.json'
jobs:
check-version:
name: Check release gate
runs-on: ubuntu-latest
outputs:
version_changed: ${{ steps.check.outputs.version_changed }}
version: ${{ steps.check.outputs.version }}
eas_build: ${{ steps.check.outputs.eas_build }}
should_release: ${{ steps.check.outputs.should_release }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Check easBuild flag (manual release)
id: check
run: |
CURRENT=$(node -p "require('./package.json').version")
PREVIOUS=$(git show HEAD~1:package.json 2>/dev/null | node -p "JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')).version" 2>/dev/null || echo "0.0.0")
# Manual release: flip "easBuild": true in package.json (version bump optional).
EAS_BUILD=$(node -p "require('./package.json').easBuild === true ? 'true' : 'false'")
if [ "$CURRENT" != "$PREVIOUS" ]; then
VERSION_CHANGED=true
else
VERSION_CHANGED=false
fi
echo "Current version: $CURRENT"
echo "Previous version: $PREVIOUS"
echo "easBuild flag: $EAS_BUILD"
echo "version_changed=$VERSION_CHANGED" >> $GITHUB_OUTPUT
echo "version=$CURRENT" >> $GITHUB_OUTPUT
echo "eas_build=$EAS_BUILD" >> $GITHUB_OUTPUT
echo "should_release=$EAS_BUILD" >> $GITHUB_OUTPUT
create-release:
name: Create GitHub Release (v${{ needs.check-version.outputs.version }})
needs: check-version
if: needs.check-version.outputs.should_release == 'true'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- name: Generate release notes from changelog.ts
run: |
cat > /tmp/gen-notes.mjs << 'SCRIPT_EOF'
import { readFileSync } from 'fs';
const version = process.argv[2];
const content = readFileSync('constants/changelog.ts', 'utf8');
function decodeString(raw) {
return raw
.replace(/\\'/g, "'")
.replace(/\\"/g, '"')
.replace(/\\n/g, '\n')
.replace(/\\\\/g, '\\');
}
function extractQuotedStrings(src) {
const out = [];
const re = /'(?:\\.|[^'\\])*'|"(?:\\.|[^"\\])*"/g;
let m;
while ((m = re.exec(src))) {
out.push(decodeString(m[0].slice(1, -1)));
}
return out;
}
/** Slice the `{ version: '…', … }` object for this version, respecting strings. */
function extractReleaseBlock(src, ver) {
const header = new RegExp(
`\\{\\s*version:\\s*'${ver.replace(/\\./g, '\\.')}',\\s*date:\\s*'([^']+)'`,
);
const match = header.exec(src);
if (!match) return null;
const startIdx = match.index;
const date = match[1];
let depth = 0;
let endIdx = -1;
for (let i = startIdx; i < src.length; i++) {
const c = src[i];
if (c === "'" || c === '"') {
const q = c;
i++;
while (i < src.length) {
if (src[i] === '\\') {
i += 2;
continue;
}
if (src[i] === q) break;
i++;
}
continue;
}
if (c === '{') depth++;
else if (c === '}') {
depth--;
if (depth === 0) {
endIdx = i;
break;
}
}
}
if (endIdx < 0) return null;
return { date, block: src.slice(startIdx, endIdx + 1) };
}
function parseSections(block) {
if (!/\bsections:\s*\[/.test(block)) return null;
const sectionRe =
/\{\s*title:\s*('(?:\\.|[^'\\])*'|"(?:\\.|[^"\\])*")\s*,\s*items:\s*\[([\s\S]*?)\]\s*,?\s*\}/g;
const sections = [];
let m;
while ((m = sectionRe.exec(block))) {
const title = decodeString(m[1].slice(1, -1));
const items = extractQuotedStrings(m[2]);
if (items.length) sections.push({ title, items });
}
return sections.length ? sections : null;
}
function parseLegacyChanges(block) {
const changesIdx = block.search(/\bchanges:\s*\[/);
if (changesIdx < 0) return null;
const open = block.indexOf('[', changesIdx);
let depth = 0;
let end = -1;
for (let i = open; i < block.length; i++) {
const c = block[i];
if (c === "'" || c === '"') {
const q = c;
i++;
while (i < block.length) {
if (block[i] === '\\') {
i += 2;
continue;
}
if (block[i] === q) break;
i++;
}
continue;
}
if (c === '[') depth++;
else if (c === ']') {
depth--;
if (depth === 0) {
end = i;
break;
}
}
}
if (end < 0) return null;
const items = extractQuotedStrings(block.slice(open + 1, end));
return items.length ? items : null;
}
const SECTION_HEADINGS = {
'New Features': "### ✨ What's New",
'Bugs Fixed': '### 🐛 Bug Fixes',
Maintenance: '### 🔧 Maintenance',
};
const release = extractReleaseBlock(content, version);
if (!release) {
process.stdout.write(`## qRemote v${version}\n\nNo changelog entry found for this version.\n`);
process.exit(0);
}
const { date, block } = release;
const dateStr = new Date(date + 'T12:00:00').toLocaleDateString('en-US', {
year: 'numeric', month: 'long', day: 'numeric',
});
const lines = [
`## qRemote v${version}`,
`**Released ${dateStr}**`,
'',
];
// Prefer sectioned entries (v3.8.23+); fall back to flat changes[].
const sections = parseSections(block);
if (sections) {
for (const section of sections) {
lines.push(SECTION_HEADINGS[section.title] ?? `### ${section.title}`);
for (const item of section.items) lines.push(`- ${item}`);
lines.push('');
}
} else {
const changes = parseLegacyChanges(block) ?? [];
const fixes = changes.filter(c => /^(fix(ed)?|bug)/i.test(c));
const features = changes.filter(c => !/^(fix(ed)?|bug)/i.test(c));
if (features.length) {
lines.push("### ✨ What's New");
features.forEach(c => lines.push(`- ${c}`));
lines.push('');
}
if (fixes.length) {
lines.push('### 🐛 Bug Fixes');
fixes.forEach(c => lines.push(`- ${c}`));
lines.push('');
}
}
process.stdout.write(lines.join('\n'));
SCRIPT_EOF
node /tmp/gen-notes.mjs "${{ needs.check-version.outputs.version }}" > /tmp/release-notes.md
echo "--- Release notes preview ---"
cat /tmp/release-notes.md
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: v${{ needs.check-version.outputs.version }}
name: qRemote v${{ needs.check-version.outputs.version }}
body_path: /tmp/release-notes.md
make_latest: "true"
# Same-version re-release (easBuild without a bump) updates the existing tag/notes.
target_commitish: ${{ github.sha }}
build:
name: Build iOS (${{ needs.check-version.outputs.version }})
needs: check-version
if: needs.check-version.outputs.should_release == 'true'
runs-on: ubuntu-latest
timeout-minutes: 90
outputs:
build_id: ${{ steps.build.outputs.build_id }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Setup EAS
uses: expo/expo-github-action@v8
with:
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
- name: Build iOS (production)
id: build
run: |
BUILD_OUTPUT=$(eas build \
--platform ios \
--profile production \
--non-interactive \
--wait \
--json)
BUILD_ID=$(echo "$BUILD_OUTPUT" | jq -r '.[0].id')
echo "build_id=$BUILD_ID" >> $GITHUB_OUTPUT
echo "Build ID: $BUILD_ID"
submit:
name: Submit to App Store (${{ needs.check-version.outputs.version }})
needs: [check-version, build]
if: needs.check-version.outputs.should_release == 'true'
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Setup EAS
uses: expo/expo-github-action@v8
with:
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
- name: Submit to App Store
run: |
eas submit \
--platform ios \
--id ${{ needs.build.outputs.build_id }} \
--non-interactive \
--no-wait