Skip to content

Commit

Permalink
Merge pull request #19636 from github/feature-versioning
Browse files Browse the repository at this point in the history
Feature-based versioning
  • Loading branch information
sarahs authored Jun 23, 2021
2 parents fb3d0f1 + 68d48f3 commit 31188d9
Show file tree
Hide file tree
Showing 12 changed files with 363 additions and 26 deletions.
4 changes: 3 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ COPY tsconfig.json ./tsconfig.json

RUN npx tsc

# We need to copy data in order to do the build
COPY --chown=node:node data ./data

RUN npm run build

# --------------------------------------------------------------------------------
Expand Down Expand Up @@ -85,7 +88,6 @@ ENV AIRGAP true
# Copy only what's needed to run the server
COPY --chown=node:node assets ./assets
COPY --chown=node:node content ./content
COPY --chown=node:node data ./data
COPY --chown=node:node includes ./includes
COPY --chown=node:node layouts ./layouts
COPY --chown=node:node lib ./lib
Expand Down
52 changes: 52 additions & 0 deletions data/features/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
## Feature-based versioning

Feature-based versioning allows us to define and control the versions of an arbitrarily named "feature" in one place.

**Note**: Do not delete `data/features/placeholder.yml` because it is used by tests.

## How it works

Add a new YAML file with the feature name you want to use in this directory. For a feature named `meow`, that would be `data/features/meow.yml`.

Add a `versions` block to the YML file with the short names of the versions the feature is available in. For example:

```yaml
versions:
fpt: '*'
ghes: '>3.1'
ghae: '*'
```
The format and allowed values are the same as the [frontmatter versions property](/content#versions).
### Liquid conditionals
Now you can use `{% if meow %} ... {% endif %}` in content files! Note this is the `if` tag, not the new `ifversion` tag.

### Frontmatter

You can also use the feature in frontmatter in content files:

```yaml
versions:
fpt: '*'
ghes: '>3.1'
feature: 'meow'
```

If you want a content file to apply to more than one feature, you can do this:

```yaml
versions:
fpt: '*'
ghes: '>3.1'
feature: ['meow', 'blorp']
```

## Schema enforcement

The schema for validating the feature versioning lives in [`tests/helpers/schemas/feature-versions.js`](tests/helpers/schemas/feature-versions.js) and is exercised by [`tests/content/lint-files.js`](tests/content/lint-files.js).

## Script to remove feature tags

TBD!
4 changes: 4 additions & 0 deletions data/features/placeholder.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Do not delete! Used by tests.
versions:
ghes: '>3.0'
ghae: '*'
28 changes: 24 additions & 4 deletions lib/frontmatter.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
const fs = require('fs')
const path = require('path')
const parse = require('./read-frontmatter')
const semver = require('semver')
const layouts = require('./layouts')
Expand All @@ -9,8 +11,10 @@ const semverRange = {
conform: semverValidRange,
message: 'Must be a valid SemVer range'
}
const versionIds = Object.keys(require('./all-versions'))
const versionObjs = Object.values(require('./all-versions'))
const guideTypes = ['overview', 'quick_start', 'tutorial', 'how_to', 'reference']
const featureVersions = fs.readdirSync(path.posix.join(process.cwd(), 'data/features'))
.map(file => path.basename(file, '.yml'))

const schema = {
properties: {
Expand Down Expand Up @@ -197,15 +201,31 @@ const schema = {
}
}

const featureVersionsProp = {
feature: {
type: ['string', 'array'],
enum: featureVersions,
items: {
type: 'string'
},
message: 'must be the name (or names) of a feature that matches "filename" in data/features/_filename_.yml'
}
}

schema.properties.versions = {
type: ['object', 'string'], // allow a '*' string to indicate all versions
required: true,
properties: versionIds.reduce((acc, versionId) => {
acc[versionId] = semverRange
properties: versionObjs.reduce((acc, versionObj) => {
acc[versionObj.plan] = semverRange
acc[versionObj.shortName] = semverRange
return acc
}, {})
}, featureVersionsProp)
}

// Support 'github-ae': next
schema.properties.versions.properties['github-ae'] = 'next'
schema.properties.versions.properties.ghae = 'next'

function frontmatter (markdown, opts = {}) {
const defaults = {
schema,
Expand Down
73 changes: 63 additions & 10 deletions lib/get-applicable-versions.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,17 @@
const path = require('path')
const { reduce, sortBy } = require('lodash')
const allVersions = require('./all-versions')
const versionSatisfiesRange = require('./version-satisfies-range')
const checkIfNextVersionOnly = require('./check-if-next-version-only')
const dataDirectory = require('./data-directory')
const encodeBracketedParentheses = require('./encode-bracketed-parentheses')
const featuresDir = path.posix.join(__dirname, '../data/features')

const featureData = dataDirectory(featuresDir, {
preprocess: dataString =>
encodeBracketedParentheses(dataString.trimEnd()),
ignorePatterns: [/README\.md$/]
})

// return an array of versions that an article's product versions encompasses
function getApplicableVersions (frontmatterVersions, filepath) {
Expand All @@ -13,17 +24,63 @@ function getApplicableVersions (frontmatterVersions, filepath) {
return Object.keys(allVersions)
}

// get an array like: [ 'free-pro-team@latest', '[email protected]', 'enterprise-cloud@latest' ]
const applicableVersions = []
// Check for frontmatter that includes a feature name, like:
// fpt: '*'
// feature: 'foo'
// or multiple feature names, like:
// fpt: '*'
// feature: ['foo', 'bar']
// and add the versions affiliated with the feature (e.g., foo) to the frontmatter versions object:
// fpt: '*'
// ghes: '>=2.23'
// ghae: '*'
// where the feature is bringing the ghes and ghae versions into the mix.
const featureVersions = reduce(frontmatterVersions, (result, value, key) => {
if (key === 'feature') {
if (typeof value === 'string') {
Object.assign(result, { ...featureData[value].versions })
} else if (Array.isArray(value)) {
value.forEach(str => {
Object.assign(result, { ...featureData[str].versions })
})
}
delete result[key]
}
return result
}, {})

// We will be evaluating feature versions separately, so we can remove this.
delete frontmatterVersions.feature

// Get available versions for frontmatter and for feature versions.
const foundFeatureVersions = evaluateVersions(featureVersions)
const foundFrontmatterVersions = evaluateVersions(frontmatterVersions)

// Combine them!
const applicableVersions = [...new Set(foundFrontmatterVersions.versions.concat(foundFeatureVersions.versions))]

if (!applicableVersions.length && !foundFrontmatterVersions.isNextVersionOnly && !foundFeatureVersions.isNextVersionOnly) {
throw new Error(`No applicable versions found for ${filepath}. Please double-check the page's \`versions\` frontmatter.`)
}

// Sort them by the order in lib/all-versions.
const sortedVersions = sortBy(applicableVersions, (v) => { return Object.keys(allVersions).indexOf(v) })

return sortedVersions
}

function evaluateVersions (versionsObj) {
let isNextVersionOnly = false

// where frontmatter is something like:
// get an array like: [ 'free-pro-team@latest', '[email protected]', 'enterprise-cloud@latest' ]
const versions = []

// where versions obj is something like:
// fpt: '*'
// ghes: '>=2.19'
// ghae: '*'
// ^ where each key corresponds to a plan's short name (defined in lib/all-versions.js)
Object.entries(frontmatterVersions)
Object.entries(versionsObj)
.forEach(([plan, planValue]) => {
// Special handling for frontmatter that evalues to the next GHES release number or a hardcoded `next`.
isNextVersionOnly = checkIfNextVersionOnly(planValue)
Expand All @@ -37,16 +94,12 @@ function getApplicableVersions (frontmatterVersions, filepath) {
const versionToCompare = relevantVersion.hasNumberedReleases ? relevantVersion.currentRelease : '1.0'

if (versionSatisfiesRange(versionToCompare, planValue)) {
applicableVersions.push(relevantVersion.version)
versions.push(relevantVersion.version)
}
})
})

if (!applicableVersions.length && !isNextVersionOnly) {
throw new Error(`No applicable versions found for ${filepath}. Please double-check the page's \`versions\` frontmatter.`)
}

return applicableVersions
return { versions, isNextVersionOnly }
}

module.exports = getApplicableVersions
18 changes: 18 additions & 0 deletions middleware/contextualizers/features.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
const getApplicableVersions = require('../../lib/get-applicable-versions')

module.exports = async function features (req, res, next) {
if (!req.context.page) return next()

// Determine whether the currentVersion belongs to the list of versions the feature is available in.
Object.keys(req.context.site.data.features).forEach(featureName => {
const { versions } = req.context.site.data.features[featureName]
const applicableVersions = getApplicableVersions(versions, req.path)

// Adding the resulting boolean to the context object gives us the ability to use
// `{% if featureName ... %}` conditionals in content files.
const isFeatureAvailableInCurrentVersion = applicableVersions.includes(req.context.currentVersion)
req.context[featureName] = isFeatureAvailableInCurrentVersion
})

return next()
}
2 changes: 2 additions & 0 deletions middleware/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ const currentProductTree = require('./contextualizers/current-product-tree')
const genericToc = require('./contextualizers/generic-toc')
const breadcrumbs = require('./contextualizers/breadcrumbs')
const earlyAccessBreadcrumbs = require('./contextualizers/early-access-breadcrumbs')
const features = require('./contextualizers/features')
const productExamples = require('./contextualizers/product-examples')
const devToc = require('./dev-toc')
const featuredLinks = require('./featured-links')
Expand Down Expand Up @@ -180,6 +181,7 @@ module.exports = function (app) {
app.use(asyncMiddleware(instrument(genericToc, './contextualizers/generic-toc')))
app.use(asyncMiddleware(instrument(breadcrumbs, './contextualizers/breadcrumbs')))
app.use(asyncMiddleware(instrument(earlyAccessBreadcrumbs, './contextualizers/early-access-breadcrumbs')))
app.use(asyncMiddleware(instrument(features, './contextualizers/features')))
app.use(asyncMiddleware(instrument(productExamples, './contextualizers/product-examples')))

app.use(asyncMiddleware(instrument(devToc, './dev-toc')))
Expand Down
7 changes: 7 additions & 0 deletions tests/fixtures/feature-versions-frontmatter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
title: Some article only versioned for FPT
versions:
fpt: '*'
ghes: '>2.21'
feature: 'placeholder'
---
18 changes: 18 additions & 0 deletions tests/helpers/schemas/feature-versions-schema.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
const { schema } = require('../../../lib/frontmatter')

// Copy the properties from the frontmatter schema.
const featureVersions = {
properties: {
versions: Object.assign({}, schema.properties.versions)
}
}

// Remove the feature versions properties.
// We don't want to allow features within features! We just want pure versioning.
delete featureVersions.properties.versions.properties.feature

// Call it invalid if any properties other than version properties are found.
featureVersions.additionalProperties = false
featureVersions.properties.versions.additionalProperties = false

module.exports = featureVersions
Loading

0 comments on commit 31188d9

Please sign in to comment.